diff --git a/docs/guide/api.md b/docs/guide/api.md index 4c01c8a..e3c449e 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -98,8 +98,9 @@ The same object plus: - `participants`: `userName`, `displayName`, `mandatory`, `timezone`, `onVacation`, `workingDays` (their own days on this standup, or `null` to - follow its days), `otherStandups` (how many other standups they're on) and `source` - (`manual` or `group`); + follow its days), `otherStandups` (how many other standups they're on), `source` + (`manual` or `group`), and `reportingManager` and `reportingManagerName` (`null` + when none is set); - `admins`; - `keptOff`: group members taken off by hand, whom syncs keep off; - `roster`: `group`, `syncedAt` and `error` for either kind of roster, and @@ -134,9 +135,10 @@ Each route needs a permission on that standup: `standups.configure` for `PATCH`, | `POST` | `/standups/:id/archive`, `/unarchive` | Stops or resumes prompts; history stays | | `DELETE` | `/standups/:id` | `{ "confirm": "" }` removes an **archived** standup with every run, answer, blocker and poll, and returns the counts; `409 active` until it is archived, `400 invalid` for a wrong name. People's own weeks and days off stay; messages already posted in Chat are not touched | | `POST` | `/standups/:id/participants` | `{ userName: "users/…", displayName, mandatory? }` → `201` with `reachable` (can the bot DM them) | -| `PATCH` | `/standups/:id/participants/:userName` | `{ mandatory?, onVacation?, admin? }`; `409 last_admin` guards the last admin. `:userName` is the Chat resource name URL-encoded (`users%2F123`) | +| `PATCH` | `/standups/:id/participants/:userName` | `{ mandatory?, onVacation?, admin?, reportingManager? }`; `reportingManager` is a `users/…` name from `/manager-candidates`, or `null` to clear it, and `400 invalid` refuses anyone else or the person themselves. `409 last_admin` guards the last admin. `:userName` is the Chat resource name URL-encoded (`users%2F123`) | | `DELETE` | `/standups/:id/participants/:userName` | `204`. On a group roster the person is kept off: later syncs don't add them back | | `GET` | `/standups/:id/webhook/secret` | `{ secret }`: the key the standup's webhooks are signed with ([webhooks](./configuration#outbound-webhooks)); `503 unavailable` when the server has no signing key | +| `GET` | `/standups/:id/manager-candidates` | `{ managers: [{ userName, displayName }] }`: everyone who manages an active standup, who a reporting manager is picked from | | `GET` | `/standups/:id/members` | Members of the standup's Chat space, as roster candidates; `502 chat_unavailable` carries Google's reason | | `POST` | `/standups/:id/roster/sync` | Sync the roster now → `{ added: [names], removed: [names], skipped: [{ email, reason }], syncedAt }`. A group roster follows the group. A roster picked from the space is checked against the space: `removed` were taken off for leaving it, `added` put back after rejoining. `502 roster_unavailable` when Google refuses or the space can't be checked (the message says why, and nobody is taken off), `503` when the server has no roster support | | `GET` | `/standups/:id/export.csv?days=90` | CSV download | @@ -189,8 +191,8 @@ Schedules of others need `schedules.manage` on a standup the person is on: | `PATCH` | `/people/:userName/schedule` | `{ workingDays, standupId }` — needs `schedules.manage` on that standup; the person is DM'd | | `POST` | `/people/:userName/overrides` | Same body as `/me/overrides`; managers may set past dates and are never subject to the policy — the person is DM'd | | `DELETE` | `/people/:userName/overrides/:date` | Withdraw | -| `GET` | `/requests` | Pending time-off requests: all of them with `schedules.manage` across the workspace, otherwise those of people on standups where the caller has it | -| `POST` | `/requests/:ids/approve` · `/requests/:ids/decline` | `:ids` comma-separated (one request can span several dates); `{ note? }` on decline goes to the person; `409 refused` once decided or when the caller doesn't manage the person | +| `GET` | `/requests` | Pending time-off requests: all of them with `schedules.manage` across the workspace, otherwise those of people on standups where the caller has it, plus the day-off requests of people the caller is reporting manager for on a standup that needs approval | +| `POST` | `/requests/:ids/approve` · `/requests/:ids/decline` | `:ids` comma-separated (one request can span several dates); `{ note? }` on decline goes to the person; `409 refused` once decided, or when the caller neither manages the person nor is their reporting manager for a day off | ### Me diff --git a/docs/guide/roles.md b/docs/guide/roles.md index ade5d5e..efdbdda 100644 --- a/docs/guide/roles.md +++ b/docs/guide/roles.md @@ -101,6 +101,11 @@ someone one of the standup's admins. That gives them Manager there, and `status` lists them as its admins. Use a grant for access without being named as an admin. +A **reporting manager** set under **Settings → People** is not a role. It lets +someone who manages a standup decide one person's day-off requests on another +standup that needs approval, with no other access there +([Personal schedules](./schedules)). + ## What each person sees - **Web app.** Navigation, settings sections, standup tabs and every button diff --git a/docs/guide/schedules.md b/docs/guide/schedules.md index c013502..31775dd 100644 --- a/docs/guide/schedules.md +++ b/docs/guide/schedules.md @@ -107,6 +107,15 @@ changing them is bound by that standup's policy only. Under *Needs approval*, the first of the person's managers to act wins; workspace admins can act on any request, and nobody decides their own. + +A person can also have a **reporting manager** on a standup, picked under +**Settings → People** from the people who manage a standup. On a standup that +needs approval, their day-off requests also go to that reporting manager, who +can approve or decline them like the standup's managers, without managing the +standup. The request's reply names them: *Sent to Neha Kapoor and the managers +of Engineering for approval*. A reporting manager gets nothing for working +requests, or on a *Self-service* or *Managers only* standup, and loses the say +once they no longer manage any standup. Declining asks for a one-line reason, which is sent to the person. A request unanswered by the deadline of a standup that needs approval lapses — the person counts as expected for that run and is told so. diff --git a/docs/guide/web-app.md b/docs/guide/web-app.md index 98442ad..84a188b 100644 --- a/docs/guide/web-app.md +++ b/docs/guide/web-app.md @@ -68,6 +68,7 @@ rosters](./directory)). Per person: | --- | --- | | On standup | Prompted for this standup | | Required | Listed as missing when they don't answer. The others still get the prompt but never count as missing | +| Reports to | A manager who can also approve their days off on this standup, without managing it; *None* by default. Only people who manage a standup are offered. The create form doesn't show it | | Mon–Sun | The days they work on this standup. The standup's days are marked *default*; any other day can be ticked, and the standup then runs that day for them. Each row says *Default* or *Custom* (with *Reset to default*), and each tick saves at once and shows *Saved*. Unticked days mean no prompt, and never missing | **Roster** switches between *Picked from the space* and *Everyone in a Google diff --git a/specs/009-reporting-managers.md b/specs/009-reporting-managers.md new file mode 100644 index 0000000..1864684 --- /dev/null +++ b/specs/009-reporting-managers.md @@ -0,0 +1,115 @@ +# 009: Reporting managers can approve their reports' days off + +| | | +| --- | --- | +| Status | Done | +| Size | Cross-cutting (migration, API, permissions, web app) | +| Figma | [Specs › 009 Reporting managers](https://www.figma.com/design/OQfcL61rbaGakS2FjBKasI?node-id=195-136) | +| PR | [#114](https://github.com/asyncup-dev/asyncup/pull/114) | + +Proposed by @shrey-CDZ in #113; this spec carries that proposal forward with +the review's fixes. + +## Problem + +Under the `approval` time-off policy, only workspace admins and the standup's +managers can decide a day-off request. The right approver is often a person's +line manager, who isn't a manager of that standup. Making them one hands over +the whole standup's configuration just to sign off leave. We want a per-standup +reporting manager, picked from people who already manage a standup, who can +approve their reports' days off there. + +## User stories + +- As a standup manager, I want to set who each person reports to on this + standup, so the right person signs off their days off. +- As a reporting manager, I want my reports' day-off requests to reach me, and + to approve or decline them in Chat or the web app. +- As someone on two standups, I want each standup's reporting manager to have a + say only through that standup. + +## Acceptance criteria + +Setting a reporting manager + +1. Given someone with `people.manage` on a standup, when they pick a person's + reporting manager in that standup's People table, then it is saved for that + person on that standup and shown on their row. +2. The picker lists only people who manage at least one active standup. + Anyone else is refused: "A reporting manager must already be a manager on a + standup." +3. Nobody can report to themselves: "Someone can't report to themselves." +4. Choosing "None" clears it. +5. It is per standup: the same person can have a different reporting manager + on each of their standups. +6. Adding someone back to a roster by hand starts them with no reporting + manager. + +Requests and decisions + +7. Given a standup on `approval`, when a person asks for a day off, then the + request card goes to that standup's managers and to the person's reporting + manager on it, once each. +8. The person's reply names the reporting manager: "📨 Sent to Neha Kapoor and the + managers of Engineering for approval — …". It stays as today when there is + no reporting manager or they are already a manager of that standup. +9. A working request (working on a day off) does not go to the reporting + manager, and they cannot decide it. +10. A reporting manager can decide a day-off request only while both hold: + they are the person's reporting manager on one of the person's standups + that uses `approval`, and they still manage at least one active standup. + Otherwise: "Only Bob's managers can decide this." This holds for the Chat + card, the web app and the API. +11. A reporting manager on a `self` or `managers` standup gains nothing: they + don't get the card and can't decide the request. +12. A reporting manager's decision works exactly as a manager's does today: + the first decision wins, the runs are updated, the person is told, the + webhook fires, and Calendar and Chat status follow through `LeavePresence`. +13. Team › Requests shows a reporting manager their reports' pending day-off + requests on the standups from criterion 10, and not their working requests. +14. A reporting manager still can't decide their own request: "Your own + requests go to your managers." + +## Non-goals + +- Half-day leave, approval chains, delegates, or one approval per standup. +- Reporting managers changing someone's week or marking them away directly. +- Taking the reporting line from the Google Directory or SCIM. +- MCP tools. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | Standup › Settings › People: a **Reports to** column with a picker of managers and **None**, behind `people.manage`. On a phone it is one more line on each person's card. Not shown when creating a standup. Team › Requests is unchanged; it lists more requests for a reporting manager | +| Chat cards and dialogs | No new card. The requester's reply names the reporting manager (criterion 8) | +| API and OpenAPI | `PATCH /standups/:id/participants/:userName` takes `reportingManager` (`users/…` or `null`); participants return `reportingManager` and `reportingManagerName`. New `GET /standups/:id/manager-candidates` (`people.manage`). `GET /requests` and `POST /requests/:id/{approve,decline}` admit a reporting manager under criterion 10 | +| MCP | None | +| Database | Migration 22, both dialects: `participants.reporting_manager` and `reporting_manager_name`, and an index on `reporting_manager` | +| Permissions | None new. Setting it needs `people.manage`; deciding comes from the relationship, alongside `schedules.manage` | +| Docs | `schedules.md` (who approves), `roles.md`, `web-app.md` (People table), `api.md` | +| Website | None | +| Google APIs and scopes | None | + +How it works: + +- The reporting manager is two columns on the `participants` row, so it is per + standup by construction. +- Candidates are the distinct people in `standup_admins` on the tenant's + active standups. The same query checks a pick, and checks a decider still + manages somewhere (criterion 10). +- `setOverride` already loops over the standups that need approval and DMs + their managers. It also DMs the person's reporting manager on each of those + standups, skipping anyone already told. +- `decide` adds one check after the manager check: not a working request, the + actor is the person's reporting manager on one of the person's `approval` + standups, and the actor manages an active standup. +- `GET /requests` adds the actor's reports from criterion 10 to the people it + lists, then keeps only their day-off requests. +- Removing someone leaves the column in place but inactive rows grant nothing; + adding them back by hand clears it (criterion 6). A roster sync that brings + someone back keeps it. + +## Open questions + +None. diff --git a/src/api/schedule.ts b/src/api/schedule.ts index e3fc256..2de4973 100644 --- a/src/api/schedule.ts +++ b/src/api/schedule.ts @@ -227,19 +227,25 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { // ---- approvals + const reportsOf = async (p: Principal) => (p.user?.userName ? repo.listReportsOf(p.tenantId, p.user.userName) : []); + api.get('/requests', async (req, res) => { if (!schedule(res)) return; const p = principalOf(req); - if (!canSomewhere(p.access, 'schedules.manage')) { + const reports = await reportsOf(p); + if (!canSomewhere(p.access, 'schedules.manage') && reports.length === 0) { apiError(res, 403, 'forbidden', 'Only admins and managers see requests.'); return; } - let names: string[] | undefined; - if (!allowed(p, 'schedules.manage')) { - names = []; - for (const id of standupsWith(p.access, 'schedules.manage')) for (const x of await repo.listParticipants(id)) names.push(x.userName); + if (allowed(p, 'schedules.manage')) { + res.json({ requests: (await repo.listPendingOverrides()).map(overrideView) }); + return; } - res.json({ requests: (await repo.listPendingOverrides(names)).map(overrideView) }); + const managed = new Set(); + for (const id of standupsWith(p.access, 'schedules.manage')) for (const x of await repo.listParticipants(id)) managed.add(x.userName); + const requests = await repo.listPendingOverrides([...new Set([...managed, ...reports])]); + // A reporting manager decides days off only, so working requests show only to the person's managers. + res.json({ requests: requests.filter((o) => managed.has(o.userName) || !o.working).map(overrideView) }); }); for (const decision of ['approve', 'decline'] as const) { @@ -247,7 +253,7 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { const svc = schedule(res); if (!svc) return; const p = principalOf(req); - if (!canSomewhere(p.access, 'schedules.manage')) { + if (!canSomewhere(p.access, 'schedules.manage') && (await reportsOf(p)).length === 0) { apiError(res, 403, 'forbidden', 'Only admins and managers decide requests.'); return; } diff --git a/src/api/spec/routes.ts b/src/api/spec/routes.ts index 726da96..5c25d34 100644 --- a/src/api/spec/routes.ts +++ b/src/api/spec/routes.ts @@ -94,6 +94,7 @@ export const ROUTES: RouteSpec[] = [ }, { method: 'get', path: '/standups/:id/webhook/secret', summary: 'The key this standup’s webhooks are signed with', tag: 'Standups', access: 'manager', permission: 'standups.configure', params: standupId, responses: { 200: S.WebhookSecret }, errors: [403, 404, 503] }, { method: 'get', path: '/standups/:id/members', summary: 'Members of the standup’s Chat space, as roster candidates', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.SpaceMembers }, errors: [404, 502] }, + { method: 'get', path: '/standups/:id/manager-candidates', summary: 'People who can be picked as a reporting manager', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.ManagerCandidates }, errors: [404] }, { method: 'post', path: '/standups/:id/roster/sync', summary: 'Sync the roster with its Google Group, or with its Chat space, now', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, responses: { 200: S.RosterSynced }, errors: [404, 502, 503] }, { method: 'post', path: '/standups/:id/participants', summary: 'Add someone to the roster', tag: 'Roster', access: 'manager', permission: 'people.manage', params: standupId, body: S.AddParticipant, responses: { 201: S.AddedParticipant }, errors: [400, 404] }, { diff --git a/src/api/spec/schemas.ts b/src/api/spec/schemas.ts index bcdff68..e47711d 100644 --- a/src/api/spec/schemas.ts +++ b/src/api/spec/schemas.ts @@ -110,6 +110,8 @@ export const StandupDetail = named( workingDays: z.string().nullable(), otherStandups: count, source: z.enum(['manual', 'group']), + reportingManager: z.string().nullable().describe('Who else approves this person’s days off on this standup; null when nobody beyond its managers.'), + reportingManagerName: z.string().nullable(), }), ), admins: z.array(Person), @@ -226,7 +228,15 @@ export const AddedParticipant = named( ); export const UpdatedParticipant = named( 'UpdatedParticipant', - z.strictObject({ userName: z.string(), displayName: z.string(), mandatory: z.boolean(), onVacation: z.boolean(), admin: z.boolean() }), + z.strictObject({ + userName: z.string(), + displayName: z.string(), + mandatory: z.boolean(), + onVacation: z.boolean(), + admin: z.boolean(), + reportingManager: z.string().nullable(), + reportingManagerName: z.string().nullable(), + }), ); // --- blockers and people @@ -288,6 +298,7 @@ export const Spaces = named( ); export const WebhookSecret = named('WebhookSecret', z.strictObject({ secret: z.string() }), 'HMAC-SHA256 key for the X-AsyncUp-Signature header. It changes only when SECRET_KEY does.'); export const SpaceMembers = named('SpaceMembers', z.strictObject({ members: z.array(Person) })); +export const ManagerCandidates = named('ManagerCandidates', z.strictObject({ managers: z.array(Person) }), 'Everyone who manages an active standup: who a reporting manager is picked from.'); // --- the caller's own view @@ -602,7 +613,17 @@ export const CreateStandup = named( ); export const AddParticipant = named('AddParticipant', z.strictObject({ userName: z.string().startsWith('users/'), displayName: text.min(1), mandatory: z.boolean().optional() })); -export const UpdateParticipant = named('UpdateParticipant', z.strictObject({ mandatory: z.boolean(), onVacation: z.boolean(), admin: z.boolean() }).partial()); +export const UpdateParticipant = named( + 'UpdateParticipant', + z + .strictObject({ + mandatory: z.boolean(), + onVacation: z.boolean(), + admin: z.boolean(), + reportingManager: z.string().startsWith('users/').nullable().describe('Must already manage a standup, and not be this person. null clears it.'), + }) + .partial(), +); export const BlockerUpdateBody = named('BlockerUpdateBody', z.strictObject({ text: text.min(1) })); export const SkipBody = named('SkipBody', z.strictObject({ standupId: id, reason: z.string().min(1).max(LIMITS.skipNoteMax) }), 'The reason is one line, posted in the day’s thread.'); export const MyPatch = named('MyPatch', z.strictObject({ timezone: z.string().nullable(), onVacation: z.boolean() }).partial(), 'A null or empty timezone follows the standups’ zones again.'); diff --git a/src/api/standups.ts b/src/api/standups.ts index a818efd..dfaa657 100644 --- a/src/api/standups.ts +++ b/src/api/standups.ts @@ -165,6 +165,12 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void { } }); + api.get('/standups/:id/manager-candidates', async (req, res) => { + const standup = await loadStandup(ctx, req, res, { need: 'people.manage' }); + if (!standup) return; + res.json({ managers: await repo.listManagerCandidates(standup.tenantId) }); + }); + // Derived from SECRET_KEY, so it only changes when that does; managers need it to check signatures. api.get('/standups/:id/webhook/secret', async (req, res) => { const standup = await loadStandup(ctx, req, res, { need: 'standups.configure' }); @@ -216,6 +222,15 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void { return; } } + const rm = body.reportingManager; + let manager: { userName: string; displayName: string } | null | undefined; + if (rm === null) manager = null; + else if (rm !== undefined) { + if (rm === userName) return apiError(res, 400, 'invalid', "Someone can't report to themselves.", 'reportingManager'); + manager = (await repo.listManagerCandidates(standup.tenantId)).find((c) => c.userName === rm); + if (!manager) return apiError(res, 400, 'invalid', 'A reporting manager must already be a manager on a standup.', 'reportingManager'); + } + if (manager !== undefined) await repo.setReportingManager(standup.id, userName, manager); if (body.mandatory !== undefined) await repo.setParticipantMandatory(standup.id, userName, body.mandatory); if (body.onVacation !== undefined) await repo.setParticipantVacation(standup.id, userName, body.onVacation); if (body.admin === true) await repo.addAdmin(standup.id, userName, participant.displayName); @@ -234,6 +249,8 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void { mandatory: fresh.mandatory, onVacation: fresh.onVacation, admin: await repo.isAdmin(standup.id, userName), + reportingManager: fresh.reportingManager, + reportingManagerName: fresh.reportingManagerName, }); }); diff --git a/src/api/views.ts b/src/api/views.ts index 1e110bc..4905c37 100644 --- a/src/api/views.ts +++ b/src/api/views.ts @@ -174,6 +174,8 @@ export async function standupDetailView(repo: Repo, standup: Standup, now: DateT onVacation: x.onVacation, workingDays: x.workingDays, source: x.source, + reportingManager: x.reportingManager, + reportingManagerName: x.reportingManagerName, // A personal week is the person's, so the console warns when changing it here reaches other standups too. otherStandups: (await repo.listStandupsForUser(x.userName)).length - 1, })), diff --git a/src/core/schedule.ts b/src/core/schedule.ts index 00a0a1c..4c438d5 100644 --- a/src/core/schedule.ts +++ b/src/core/schedule.ts @@ -369,9 +369,16 @@ export class ScheduleService { if (status === 'pending') { const request: Omit = { ids: saved.map((o) => o.id), person: target, dates, working, reason }; const told = new Set(); + const reportingManagers: string[] = []; for (const standup of approvers) { await this.webhooks?.timeOffRequest(standup, request); - for (const admin of await this.repo.listAdmins(standup.id)) { + const recipients = await this.repo.listAdmins(standup.id); + const rm = working ? null : await this.repo.getReportingManager(standup.id, target.userName); + if (rm && rm.userName !== target.userName && !recipients.some((a) => a.userName === rm.userName) && !told.has(rm.userName)) { + recipients.push({ standupId: standup.id, ...rm }); + reportingManagers.push(rm.displayName); + } + for (const admin of recipients) { if (told.has(admin.userName)) continue; told.add(admin.userName); try { @@ -382,7 +389,8 @@ export class ScheduleService { } } const names = [...new Set(approvers.map((s) => s.name))].join(', '); - return { ok: true, status, message: `📨 Sent to the managers of ${names} for approval — you'll hear back here. Withdraw with \`off cancel ${dates[0]}\`.` }; + const to = reportingManagers.length ? `${reportingManagers.join(', ')} and the managers` : 'the managers'; + return { ok: true, status, message: `📨 Sent to ${to} of ${names} for approval — you'll hear back here. Withdraw with \`off cancel ${dates[0]}\`.` }; } await this.recordChange(target, `${target.displayName} is ${what}`, actor, self, channel, standups, standups); await this.applyToRuns(target.userName, dates); @@ -458,15 +466,17 @@ export class ScheduleService { if (new Set(pending.map((o) => o.userName)).size > 1) return { ok: false, message: 'Decide one person’s request at a time.' }; const person = { userName: pending[0]!.userName, displayName: pending[0]!.displayName }; const standups = await this.repo.listStandupsForUser(person.userName); + const working = pending[0]!.working; if (!actor.admin) { if (person.userName === actor.userName) return { ok: false, message: 'Your own requests go to your managers.' }; const access = standups.length ? await resolveAccess(this.repo, { tenantId: standups[0]!.tenantId, userName: actor.userName }, false) : null; - if (!access || !standups.some((s) => can(access, 'schedules.manage', s.id))) return { ok: false, message: `Only ${person.displayName}'s managers can decide this.` }; + const manages = !!access && standups.some((s) => can(access, 'schedules.manage', s.id)); + const reportsTo = !manages && !working && !!access && (await this.repo.listReportsOf(standups[0]!.tenantId, actor.userName)).includes(person.userName); + if (!manages && !reportsTo) return { ok: false, message: `Only ${person.displayName}'s managers can decide this.` }; } const at = this.now().toISO()!; for (const o of pending) await this.repo.setOverrideStatus(o.id, approve ? 'active' : 'declined', at, { byDisplayName: actor.displayName, note }); const dates = pending.map((o) => o.date).sort(); - const working = pending[0]!.working; if (approve) await this.applyToRuns(person.userName, dates); for (const standup of standups) await this.webhooks?.scheduleChange(standup, { person, summary: `${approve ? 'approved' : 'declined'}: ${working ? 'working' : 'off'} ${describeDates(dates)}`, by: actor.displayName, channel: 'chat' }); await this.dm( diff --git a/src/core/types.ts b/src/core/types.ts index 41ba6e6..77aa491 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -122,6 +122,9 @@ export interface Participant { workingDays: string | null; /** "group" rows come and go with the standup's roster group; "manual" ones stay until removed. */ source: 'manual' | 'group'; + /** Who else approves this person's days off on this standup; null = nobody beyond its managers. */ + reportingManager: string | null; + reportingManagerName: string | null; active: boolean; } diff --git a/src/db/repo.ts b/src/db/repo.ts index e7abd7f..040d586 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -502,6 +502,11 @@ CREATE TABLE question_poll_people ( updated_at TEXT, PRIMARY KEY (poll_id, user_name) ); +`, // 22 — who each person reports to on a standup, for approving their days off + ` +ALTER TABLE participants ADD COLUMN reporting_manager TEXT; +ALTER TABLE participants ADD COLUMN reporting_manager_name TEXT; +CREATE INDEX idx_participants_reporting_manager ON participants(reporting_manager); `, ]; @@ -880,6 +885,11 @@ CREATE TABLE question_poll_people ( updated_at TEXT, PRIMARY KEY (poll_id, user_name) ); +`, // 22 — who each person reports to on a standup, for approving their days off + ` +ALTER TABLE participants ADD COLUMN reporting_manager TEXT; +ALTER TABLE participants ADD COLUMN reporting_manager_name TEXT; +CREATE INDEX idx_participants_reporting_manager ON participants(reporting_manager); `, ]; @@ -987,6 +997,10 @@ function toStandup(row: any): Standup { }; } +// For a participants row `p` on standup `s`: their reporting manager still manages an active standup in that tenant. +const MANAGES_SOMEWHERE = `EXISTS (SELECT 1 FROM standup_admins a JOIN standups m ON m.id = a.standup_id + WHERE a.user_name = p.reporting_manager AND m.tenant_id = s.tenant_id AND m.active = 1)`; + function toParticipant(row: any): Participant { return { standupId: row.standup_id, @@ -997,6 +1011,8 @@ function toParticipant(row: any): Participant { onVacation: !!row.on_vacation, workingDays: row.working_days ?? null, source: row.source === 'group' ? 'group' : 'manual', + reportingManager: row.reporting_manager ?? null, + reportingManagerName: row.reporting_manager_name ?? null, active: !!row.active, }; } @@ -1316,11 +1332,53 @@ export class Repo { `INSERT INTO participants (standup_id, user_name, display_name, mandatory, active) VALUES (?, ?, ?, ?, 1) ON CONFLICT (standup_id, user_name) - DO UPDATE SET display_name = excluded.display_name, active = 1, kept_off = 0, left_space_at = NULL`, + DO UPDATE SET display_name = excluded.display_name, active = 1, kept_off = 0, left_space_at = NULL, + reporting_manager = CASE WHEN participants.active = 1 THEN participants.reporting_manager END, + reporting_manager_name = CASE WHEN participants.active = 1 THEN participants.reporting_manager_name END`, [input.standupId, input.userName, input.displayName, input.mandatory === false ? 0 : 1], ); } + // --- reporting managers --- + + async setReportingManager(standupId: number, userName: string, manager: { userName: string; displayName: string } | null): Promise { + await this.db.run( + 'UPDATE participants SET reporting_manager = ?, reporting_manager_name = ? WHERE standup_id = ? AND user_name = ? AND active = 1', + [manager?.userName ?? null, manager?.displayName ?? null, standupId, userName], + ); + } + + /** Everyone who manages an active standup in the tenant: who a reporting manager is picked from. */ + async listManagerCandidates(tenantId: string): Promise<{ userName: string; displayName: string }[]> { + const rows = await this.db.all( + `SELECT a.user_name, MIN(a.display_name) AS display_name FROM standup_admins a JOIN standups s ON s.id = a.standup_id + WHERE s.tenant_id = ? AND s.active = 1 GROUP BY a.user_name ORDER BY MIN(a.display_name)`, + [tenantId], + ); + return rows.map((r: any) => ({ userName: r.user_name, displayName: r.display_name })); + } + + /** This person's reporting manager on a standup, only while that manager still manages an active standup. */ + async getReportingManager(standupId: number, userName: string): Promise<{ userName: string; displayName: string } | null> { + const row = await this.db.get( + `SELECT p.reporting_manager, p.reporting_manager_name FROM participants p JOIN standups s ON s.id = p.standup_id + WHERE p.standup_id = ? AND p.user_name = ? AND p.active = 1 AND ${MANAGES_SOMEWHERE}`, + [standupId, userName], + ); + return row ? { userName: row.reporting_manager, displayName: row.reporting_manager_name } : null; + } + + /** Who this manager may decide days off for: their reports on active `approval` standups, while they still manage one. */ + async listReportsOf(tenantId: string, managerUserName: string): Promise { + const rows = await this.db.all( + `SELECT DISTINCT p.user_name FROM participants p JOIN standups s ON s.id = p.standup_id + WHERE s.tenant_id = ? AND p.reporting_manager = ? AND p.active = 1 AND s.active = 1 AND s.time_off_policy = 'approval' + AND ${MANAGES_SOMEWHERE} ORDER BY p.user_name`, + [tenantId, managerUserName], + ); + return rows.map((r: any) => r.user_name); + } + // --- roster from a Google Group --- /** Every roster row, on or off, with where it came from — what a roster sync compares against. */ diff --git a/tests/api-standups.test.ts b/tests/api-standups.test.ts index 7c648d2..c95d1ec 100644 --- a/tests/api-standups.test.ts +++ b/tests/api-standups.test.ts @@ -218,7 +218,7 @@ describe('api: roster', () => { expect((await patch('users/zoe', {})).status).toBe(404); expect((await (await patch('users/dave', { admin: 'yes' })).json() as any).error.field).toBe('admin'); expect(await (await patch('users/dave', { mandatory: true, onVacation: true, admin: true })).json()).toEqual({ - userName: 'users/dave', displayName: 'Dave', mandatory: true, onVacation: true, admin: true, + userName: 'users/dave', displayName: 'Dave', mandatory: true, onVacation: true, admin: true, reportingManager: null, reportingManagerName: null, }); const last = await patch('users/dave', { admin: false }); expect(last.status).toBe(409); diff --git a/tests/api.test.ts b/tests/api.test.ts index cbb9538..683eb52 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -210,9 +210,9 @@ describe('api: standups', () => { const detail = await (await asBearer(OPERATOR, `/standups/${standup.id}`)).json() as any; expect(detail.participants).toEqual([ - { userName: 'users/alice', displayName: 'Alice', mandatory: true, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual' }, - { userName: 'users/bob', displayName: 'Bob', mandatory: true, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual' }, - { userName: 'users/carol', displayName: 'Carol', mandatory: false, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual' }, + { userName: 'users/alice', displayName: 'Alice', mandatory: true, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual', reportingManager: null, reportingManagerName: null }, + { userName: 'users/bob', displayName: 'Bob', mandatory: true, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual', reportingManager: null, reportingManagerName: null }, + { userName: 'users/carol', displayName: 'Carol', mandatory: false, timezone: null, onVacation: false, workingDays: null, otherStandups: 0, source: 'manual', reportingManager: null, reportingManagerName: null }, ]); expect(detail.admins).toEqual([{ userName: 'users/alice', displayName: 'Alice' }]); expect(detail.permissions.manage).toBe(true); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index c7058b7..91383b2 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -7,8 +7,8 @@ import { POSTGRES_MIGRATIONS, Repo, SQLITE_MIGRATIONS } from '../src/db/repo.js' describe('migration 10 — AI removal', () => { it('keeps both dialect arrays aligned', () => { - expect(SQLITE_MIGRATIONS).toHaveLength(21); - expect(POSTGRES_MIGRATIONS).toHaveLength(21); + expect(SQLITE_MIGRATIONS).toHaveLength(22); + expect(POSTGRES_MIGRATIONS).toHaveLength(22); for (const migrations of [SQLITE_MIGRATIONS, POSTGRES_MIGRATIONS]) { expect(migrations[9]).toContain('DROP COLUMN ai_enabled'); expect(migrations[9]).toContain("'llmApiKey'"); @@ -46,7 +46,7 @@ describe('migration 10 — AI removal', () => { const columns = (check.query('PRAGMA table_info(standups)').all() as { name: string }[]).map((c) => c.name); expect(columns).not.toContain('ai_enabled'); expect(columns).toContain('digest_enabled'); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(22); check.close(); }, 30_000); }); @@ -90,7 +90,7 @@ describe('migration 11 — MCP tokens and activity', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(22); check.close(); }); }); @@ -131,7 +131,7 @@ describe('migration 12 — personal schedules', () => { await repo.close(); const check = new Database(dbPath, { readonly: true }); - expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(22); check.close(); }); }); @@ -288,3 +288,27 @@ describe('migration 20 — asking to edit after the deadline', () => { await repo.close(); }); }); + +describe('migration 22 — reporting managers', () => { + it('adds the reporting manager to participants, indexed, in both dialects', () => { + for (const migrations of [SQLITE_MIGRATIONS, POSTGRES_MIGRATIONS]) { + expect(migrations[21]).toContain('ALTER TABLE participants ADD COLUMN reporting_manager TEXT'); + expect(migrations[21]).toContain('ALTER TABLE participants ADD COLUMN reporting_manager_name TEXT'); + expect(migrations[21]).toContain('CREATE INDEX idx_participants_reporting_manager ON participants(reporting_manager)'); + } + }); + + it('upgrades a v21 database, leaving everyone with no reporting manager', async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), 'asyncup-mig-')), 'v21.db'); + const raw = new Database(dbPath); + for (const migration of SQLITE_MIGRATIONS.slice(0, 21)) raw.exec(migration); + raw.exec("INSERT INTO standups (tenant_id, space_name, name, timezone) VALUES ('default', 'spaces/x', 'X', 'UTC');"); + raw.exec("INSERT INTO participants (standup_id, user_name, display_name) VALUES (1, 'users/a', 'Asha');"); + raw.exec('PRAGMA user_version = 21;'); + raw.close(); + + const repo = await Repo.sqlite(dbPath); + expect((await repo.listParticipants(1))[0]).toMatchObject({ userName: 'users/a', reportingManager: null, reportingManagerName: null }); + await repo.close(); + }); +}); diff --git a/tests/reporting-managers.test.ts b/tests/reporting-managers.test.ts new file mode 100644 index 0000000..14bf83c --- /dev/null +++ b/tests/reporting-managers.test.ts @@ -0,0 +1,255 @@ +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { APPROVE_REQUEST_FN } from '../src/adapters/gchat/cards.js'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import { newSession, sealSession } from '../src/auth/session.js'; +import { createServer } from '../src/server.js'; +import { makeStack, seedStandup, TENANT, TZ } from './helpers.js'; + +const bob = { userName: 'users/bob', displayName: 'Bob' }; +const neha = { userName: 'users/neha', displayName: 'Neha' }; +const asSelf = (p: { userName: string; displayName: string }) => ({ ...p, admin: false }); +const SECRET = 'rm-test-secret'; +const OPERATOR = 'rm-operator'; +const CSRF = { 'x-requested-with': 'asyncup', 'content-type': 'application/json' }; +let close: (() => void) | null = null; +afterEach(() => close?.()); + +/** Engineering needs approval and is managed by Admin; Neha manages Platform, which Bob isn't on. */ +async function world() { + const hooks: any[] = []; + const stack = await makeStack({ webhookFetch: (async (_url: any, init: any) => { hooks.push(JSON.parse(init.body)); return new Response('', { status: 200 }); }) as typeof fetch }); + const eng = await seedStandup(stack.repo); + await stack.repo.updateStandup(eng.id, { timeOffPolicy: 'approval', webhookUrl: 'https://hooks.example/asyncup' }); + await stack.repo.addAdmin(eng.id, 'users/admin', 'Admin'); + const platform = await stack.repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/platform', name: 'Platform', timezone: TZ }); + await stack.repo.addAdmin(platform.id, neha.userName, neha.displayName); + stack.clock.set('2026-06-10T10:00'); // Wednesday + const off = (dates: string[], working = false) => stack.schedule.setOverride({ target: bob, dates, working, reason: 'sick', actor: asSelf(bob), channel: 'chat' }); + const cards = (to: string) => stack.adapter.dms.filter((d) => d.kind === 'timeOffRequest' && d.userName === to); + const pendingIds = async () => (await stack.repo.listPendingOverrides([bob.userName])).map((o) => o.id); + return { ...stack, eng: (await stack.repo.getStandupById(eng.id))!, platform, hooks, off, cards, pendingIds }; +} + +async function server() { + const w = await world(); + const router = new EventRouter(w.commands, w.service, w.blockers, w.repo, TENANT, w.polls, async () => null, w.schedule); + const app = createServer({ + router, scheduler: w.scheduler, adapter: w.adapter, blockers: w.blockers, repo: w.repo, settings: w.settings, + dashboardToken: OPERATOR, skipVerification: true, secretKey: SECRET, service: w.service, schedule: w.schedule, now: w.clock.now, + }); + const srv = app.listen(0); + close = () => srv.close(); + const url = `http://127.0.0.1:${(srv.address() as AddressInfo).port}/api/v1`; + const cookieFor = (sub: string, name = sub) => `asyncup_sess=${sealSession(SECRET, newSession({ sub, email: `${sub}@o.com`, name, admin: false }))}`; + const op = (path: string, init: RequestInit = {}) => fetch(`${url}${path}`, { ...init, headers: { authorization: `Bearer ${OPERATOR}`, 'content-type': 'application/json' } }); + const as = (sub: string, path: string, init: RequestInit = {}) => fetch(`${url}${path}`, { ...init, headers: { cookie: cookieFor(sub), ...CSRF } }); + const patch = (who: string, body: unknown) => op(`/standups/${w.eng.id}/participants/${encodeURIComponent(who)}`, { method: 'PATCH', body: JSON.stringify(body) }); + return { ...w, router, op, as, patch }; +} + +describe('setting a reporting manager', () => { + it('saves a pick from the People table and shows it on the row (criterion 1)', async () => { + const { patch, op, eng } = await server(); + expect(await (await patch('users/bob', { reportingManager: neha.userName })).json()).toEqual({ + userName: 'users/bob', displayName: 'Bob', mandatory: true, onVacation: false, admin: false, reportingManager: 'users/neha', reportingManagerName: 'Neha', + }); + const detail = (await (await op(`/standups/${eng.id}`)).json()) as any; + expect(detail.participants.find((p: any) => p.userName === 'users/bob')).toMatchObject({ reportingManager: 'users/neha', reportingManagerName: 'Neha' }); + }); + + it('offers only people who manage an active standup, and refuses anyone else (criterion 2)', async () => { + const { patch, op, as, repo, eng, platform } = await server(); + const archived = await repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/old', name: 'Old', timezone: TZ }); + await repo.addAdmin(archived.id, 'users/zed', 'Zed'); + await repo.updateStandup(archived.id, { active: false }); + await repo.addAdmin(platform.id, 'users/admin', 'Admin'); // managing two standups lists them once + expect(await (await op(`/standups/${eng.id}/manager-candidates`)).json()).toEqual({ managers: [{ userName: 'users/admin', displayName: 'Admin' }, neha] }); + expect((await as('bob', `/standups/${eng.id}/manager-candidates`)).status).toBe(403); + for (const pick of ['users/carol', 'users/zed', 7]) { + const res = await patch('users/bob', { reportingManager: pick, mandatory: false }); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error).toEqual({ code: 'invalid', message: 'A reporting manager must already be a manager on a standup.', field: 'reportingManager' }); + } + // A refused pick changes nothing else in the same request. + expect((await repo.listParticipants(eng.id)).find((p) => p.userName === 'users/bob')).toMatchObject({ mandatory: true, reportingManager: null }); + }); + + it('refuses someone reporting to themselves (criterion 3)', async () => { + const { patch, repo, eng } = await server(); + await repo.upsertParticipant({ standupId: eng.id, userName: neha.userName, displayName: neha.displayName }); + expect(((await (await patch('users/neha', { reportingManager: 'users/neha' })).json()) as any).error).toEqual({ code: 'invalid', message: "Someone can't report to themselves.", field: 'reportingManager' }); + }); + + it('clears it with None (criterion 4)', async () => { + const { patch } = await server(); + await patch('users/bob', { reportingManager: neha.userName }); + expect(await (await patch('users/bob', { reportingManager: null })).json()).toMatchObject({ reportingManager: null, reportingManagerName: null }); + }); + + it('is kept per standup (criterion 5)', async () => { + const { repo, eng, platform } = await world(); + await repo.upsertParticipant({ standupId: platform.id, ...bob }); + await repo.setReportingManager(eng.id, bob.userName, neha); + await repo.setReportingManager(platform.id, bob.userName, { userName: 'users/admin', displayName: 'Admin' }); + expect((await repo.listParticipants(eng.id)).find((p) => p.userName === 'users/bob')!.reportingManager).toBe('users/neha'); + expect((await repo.listParticipants(platform.id)).find((p) => p.userName === 'users/bob')!.reportingManager).toBe('users/admin'); + }); + + it('starts someone added back by hand with none, but keeps it through a group sync (criterion 6)', async () => { + const { op, repo, eng } = await server(); + const bobRow = async () => (await repo.listParticipants(eng.id)).find((p) => p.userName === 'users/bob')!; + await repo.setReportingManager(eng.id, bob.userName, neha); + await repo.upsertParticipant({ standupId: eng.id, ...bob }); // already on: unchanged + expect((await bobRow()).reportingManager).toBe('users/neha'); + await repo.removeParticipant(eng.id, bob.userName); + await op(`/standups/${eng.id}/participants`, { method: 'POST', body: JSON.stringify({ userName: 'users/bob', displayName: 'Bob' }) }); + expect(await bobRow()).toMatchObject({ reportingManager: null, reportingManagerName: null }); + + await repo.setReportingManager(eng.id, bob.userName, neha); + await repo.dropGroupMember(eng.id, bob.userName); // a manual row stays + await repo.syncGroupMember(eng.id, bob.userName, 'Bob'); + expect((await bobRow()).reportingManager).toBe('users/neha'); + }); +}); + +describe('requests and decisions', () => { + it('sends the card to the managers and the reporting manager, once each (criterion 7)', async () => { + const { repo, eng, off, cards } = await world(); + await repo.setReportingManager(eng.id, bob.userName, neha); + await off(['2026-06-11']); + expect(cards('users/admin')).toHaveLength(1); + expect(cards('users/neha')).toHaveLength(1); + expect(cards('users/neha')[0]!.request).toMatchObject({ person: bob, dates: ['2026-06-11'], working: false, standup: { id: eng.id } }); + + await repo.setReportingManager(eng.id, bob.userName, { userName: 'users/admin', displayName: 'Admin' }); + await off(['2026-06-12']); + expect(cards('users/admin')).toHaveLength(2); + }); + + it('still asks the managers when the reporting manager cannot be reached (criterion 7)', async () => { + const { repo, eng, adapter, off, pendingIds } = await world(); + await repo.setReportingManager(eng.id, bob.userName, neha); + const send = adapter.sendTimeOffRequest.bind(adapter); + adapter.sendTimeOffRequest = async (to, request) => { + if (to === neha.userName) throw new Error('cannot DM'); + return send(to, request); + }; + expect(await off(['2026-06-11'])).toMatchObject({ ok: true, status: 'pending' }); + expect(await pendingIds()).toHaveLength(1); + }); + + it('names the reporting manager in the reply, unless there is none or they manage the standup (criterion 8)', async () => { + const { repo, eng, off } = await world(); + expect((await off(['2026-06-11'])).message).toBe("📨 Sent to the managers of Daily Standup for approval — you'll hear back here. Withdraw with `off cancel 2026-06-11`."); + await repo.setReportingManager(eng.id, bob.userName, neha); + expect((await off(['2026-06-12'])).message).toBe("📨 Sent to Neha and the managers of Daily Standup for approval — you'll hear back here. Withdraw with `off cancel 2026-06-12`."); + await repo.setReportingManager(eng.id, bob.userName, { userName: 'users/admin', displayName: 'Admin' }); + expect((await off(['2026-06-15'])).message).toStartWith('📨 Sent to the managers of Daily Standup'); + await repo.setReportingManager(eng.id, bob.userName, bob); // not possible through the API + expect((await off(['2026-06-16'])).message).toStartWith('📨 Sent to the managers of Daily Standup'); + }); + + it('leaves the reporting manager out of a working request (criterion 9)', async () => { + const { repo, schedule, eng, off, cards, pendingIds } = await world(); + await repo.setReportingManager(eng.id, bob.userName, neha); + expect(await off(['2026-06-13'], true)).toMatchObject({ ok: true, status: 'pending' }); + expect(cards('users/neha')).toHaveLength(0); + expect(await schedule.decide(await pendingIds(), true, asSelf(neha), null)).toEqual({ ok: false, message: "Only Bob's managers can decide this." }); + }); + + it('lets a reporting manager decide only while they report to them on an approval standup and still manage one (criterion 10)', async () => { + const { repo, schedule, eng, platform, off, pendingIds } = await world(); + const decide = async () => schedule.decide(await pendingIds(), false, asSelf(neha), null); + await off(['2026-06-11']); + expect(await decide()).toEqual({ ok: false, message: "Only Bob's managers can decide this." }); + + await repo.setReportingManager(eng.id, bob.userName, neha); + await repo.removeAdmin(platform.id, neha.userName); + expect(await decide()).toEqual({ ok: false, message: "Only Bob's managers can decide this." }); + await repo.addAdmin(platform.id, neha.userName, neha.displayName); + await repo.updateStandup(platform.id, { active: false }); + expect(await decide()).toEqual({ ok: false, message: "Only Bob's managers can decide this." }); + await repo.updateStandup(platform.id, { active: true }); + expect(await decide()).toEqual({ ok: true, status: 'active', message: '❌ Declined — Bob has been told.' }); + }); + + it('decides from the Chat card and through the API alike (criterion 10)', async () => { + const { repo, router, as, eng, platform, off, pendingIds } = await server(); + await repo.setReportingManager(eng.id, bob.userName, neha); + const click = async () => (await router.handle({ + type: 'CARD_CLICKED', + common: { invokedFunction: APPROVE_REQUEST_FN, parameters: { requestIds: (await pendingIds()).join(',') } }, + user: { name: neha.userName, displayName: neha.displayName, type: 'HUMAN' }, + space: { name: 'spaces/dm-neha', spaceType: 'DIRECT_MESSAGE' }, + })) as any; + await off(['2026-06-11']); + expect((await click()).text).toBe('✅ Approved — Bob has been told.'); + + await off(['2026-06-12']); + expect((await as('neha', `/requests/${(await pendingIds()).join(',')}/approve`, { method: 'POST', body: '{}' })).status).toBe(200); + await off(['2026-06-15']); + await repo.removeAdmin(platform.id, neha.userName); + expect((await click()).text).toBe("Only Bob's managers can decide this."); + expect((await as('neha', `/requests/${(await pendingIds()).join(',')}/approve`, { method: 'POST', body: '{}' })).status).toBe(403); + }); + + it('gives a reporting manager nothing on a self or managers standup (criterion 11)', async () => { + const { repo, schedule, eng, off, cards, pendingIds } = await world(); + const design = await repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/design', name: 'Design', timezone: TZ }); + await repo.upsertParticipant({ standupId: design.id, ...bob }); // Design is self, and Neha doesn't manage it + await repo.setReportingManager(design.id, bob.userName, neha); + await off(['2026-06-11']); + expect(cards('users/neha')).toHaveLength(0); + expect(await schedule.decide(await pendingIds(), true, asSelf(neha), null)).toEqual({ ok: false, message: "Only Bob's managers can decide this." }); + + await repo.updateStandup(design.id, { timeOffPolicy: 'managers' }); + expect(await off(['2026-06-12'])).toMatchObject({ ok: false }); + expect(await repo.listReportsOf(TENANT, neha.userName)).toEqual([]); + await repo.setReportingManager(eng.id, bob.userName, neha); + expect(await repo.listReportsOf(TENANT, neha.userName)).toEqual(['users/bob']); + }); + + it('applies a reporting manager’s approval as a manager’s would (criterion 12)', async () => { + const { repo, schedule, scheduler, settings, leave, leaveWriter, adapter, eng, off, pendingIds, hooks } = await world(); + await settings.update({ leaveInChat: true }); + await repo.setUserEmail(bob.userName, 'bob@example.com'); + await scheduler.tick(); + const run = (await repo.getRun(eng.id, '2026-06-10'))!; + await repo.setReportingManager(eng.id, bob.userName, neha); + await off(['2026-06-10']); + const ids = await pendingIds(); + expect(await schedule.decide(ids, true, asSelf(neha), null)).toEqual({ ok: true, status: 'active', message: '✅ Approved — Bob has been told. Their Chat status and calendar will show it.' }); + expect(await schedule.decide(ids, false, { userName: 'users/admin', displayName: 'Admin', admin: false }, null)).toEqual({ ok: false, message: 'That request was already decided or withdrawn.' }); + expect((await repo.getOverride(bob.userName, '2026-06-10'))!).toMatchObject({ status: 'active', decidedByDisplayName: 'Neha' }); + expect((await repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/bob')).toMatchObject({ awayReason: 'day_off' }); + expect(adapter.dms.at(-1)).toMatchObject({ userName: 'users/bob', text: expect.stringContaining('Neha approved your time off request for Wed 10 Jun.') }); + expect(hooks.at(-1)).toMatchObject({ event: 'schedule_change', summary: 'approved: off Wed 10 Jun', by: 'Neha' }); + await leave.sweep(); + expect(leaveWriter.calls).toEqual(['create bob@example.com', 'status bob@example.com On leave until Wed 10 Jun']); + }); + + it('shows a reporting manager their reports’ days off in Team › Requests, not their working requests (criterion 13)', async () => { + const { repo, schedule, as, eng, platform, off } = await server(); + const dev = { userName: 'users/dev', displayName: 'Dev' }; + await repo.upsertParticipant({ standupId: platform.id, ...dev }); + await repo.updateStandup(platform.id, { timeOffPolicy: 'approval' }); + await schedule.setOverride({ target: dev, dates: ['2026-06-19'], working: true, reason: '', actor: asSelf(dev), channel: 'chat' }); // Neha manages Dev + await schedule.setOverride({ target: dev, dates: ['2026-06-12'], working: false, reason: '', actor: asSelf(dev), channel: 'chat' }); + await repo.setReportingManager(eng.id, bob.userName, neha); + await off(['2026-06-11']); + await off(['2026-06-13'], true); + const seen = (await (await as('neha', '/requests')).json()) as any; + expect(seen.requests.map((r: any) => [r.displayName, r.date, r.working])).toEqual([['Bob', '2026-06-11', false], ['Dev', '2026-06-12', false], ['Dev', '2026-06-19', true]]); + expect(((await (await as('admin', '/requests')).json()) as any).requests).toHaveLength(2); // Bob's both, as his manager + expect((await as('carol', '/requests')).status).toBe(403); + }); + + it('still sends a reporting manager’s own request to their managers (criterion 14)', async () => { + const { repo, schedule, eng } = await world(); + await repo.upsertParticipant({ standupId: eng.id, ...neha }); + await schedule.setOverride({ target: neha, dates: ['2026-06-11'], working: false, reason: '', actor: asSelf(neha), channel: 'chat' }); + const ids = (await repo.listPendingOverrides([neha.userName])).map((o) => o.id); + expect(await schedule.decide(ids, true, asSelf(neha), null)).toEqual({ ok: false, message: 'Your own requests go to your managers.' }); + }); +}); diff --git a/web/src/components/people-matrix.tsx b/web/src/components/people-matrix.tsx index a7b2302..b518e3e 100644 --- a/web/src/components/people-matrix.tsx +++ b/web/src/components/people-matrix.tsx @@ -1,3 +1,4 @@ +import type { Person } from '../lib/api'; import { DAY_LABEL, tickedDays, WEEK, weekAfter, type Weekday } from '../lib/roster'; export interface MatrixRow { @@ -6,10 +7,11 @@ export interface MatrixRow { onStandup: boolean; mandatory: boolean; workingDays: string | null; + reportingManager?: string | null; badges?: string[]; } -export type MatrixChange = { onStandup: boolean } | { mandatory: boolean } | { week: string }; +export type MatrixChange = { onStandup: boolean } | { mandatory: boolean } | { week: string } | { reportsTo: string | null }; /** The outcome of the last change to a row: saved, or the error. */ export interface RowStatus { @@ -22,7 +24,7 @@ export interface RowStatus { * they work on it — one row per person in the space. The standup's own days * are the default; anyone can be given any other days. */ -export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, status = null, onChange }: { rows: MatrixRow[]; standupDays: readonly Weekday[]; busy?: boolean; weeks?: boolean; status?: RowStatus | null; onChange: (row: MatrixRow, change: MatrixChange) => void }) { +export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, status = null, managers = null, onChange }: { rows: MatrixRow[]; standupDays: readonly Weekday[]; busy?: boolean; weeks?: boolean; status?: RowStatus | null; managers?: Person[] | null; onChange: (row: MatrixRow, change: MatrixChange) => void }) { if (rows.length === 0) return

Nobody in this space yet.

; return (
@@ -32,6 +34,7 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, st Person On standup Required + {managers ? Reports to : null} {WEEK.map((d) => ( {DAY_LABEL[d]} @@ -73,6 +76,20 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, st onChange(row, { mandatory: e.target.checked })} /> + {managers ? ( + + + + ) : null} {WEEK.map((d) => (

Required people are listed as missing when they don’t answer; the others still get the prompt. A person’s days apply to this standup only. “Default” follows the standup’s days under Schedule. Tick any other day and the standup also runs that day, for that person only. Each tick saves straight away. Unticked days mean no prompt, and the person is never counted missing that day. + {managers ? ' “Reports to” is a manager who can also approve this person’s days off on this standup, without managing it.' : null}

); diff --git a/web/src/lib/people.ts b/web/src/lib/people.ts index 48dbd59..86e12eb 100644 --- a/web/src/lib/people.ts +++ b/web/src/lib/people.ts @@ -25,7 +25,7 @@ function invalidateRoster(client: ReturnType, standupId: export function useParticipantPatch() { const client = useQueryClient(); return useMutation({ - mutationFn: ({ standupId, userName, patch }: { standupId: number; userName: string; patch: { mandatory?: boolean; onVacation?: boolean; admin?: boolean } }) => + mutationFn: ({ standupId, userName, patch }: { standupId: number; userName: string; patch: { mandatory?: boolean; onVacation?: boolean; admin?: boolean; reportingManager?: string | null } }) => api(`/standups/${standupId}/participants/${encodeURIComponent(userName)}`, { method: 'PATCH', body: patch }), onSuccess: (_d, v) => invalidateRoster(client, v.standupId), }); @@ -69,6 +69,11 @@ export function useStandupMembers(id: number, enabled = true) { return useQuery({ queryKey: ['standup-members', id], queryFn: () => api<{ members: Person[] }>(`/standups/${id}/members`), select: (d) => d.members, enabled }); } +/** Everyone who manages an active standup: who a reporting manager is picked from. */ +export function useManagerCandidates(id: number, enabled = true) { + return useQuery({ queryKey: ['manager-candidates', id], queryFn: () => api<{ managers: Person[] }>(`/standups/${id}/manager-candidates`), select: (d) => d.managers, enabled }); +} + export function useStandupPatch(id: number) { const client = useQueryClient(); return useMutation({ diff --git a/web/src/lib/roster.test.tsx b/web/src/lib/roster.test.tsx index 760adde..3748ba0 100644 --- a/web/src/lib/roster.test.tsx +++ b/web/src/lib/roster.test.tsx @@ -52,6 +52,14 @@ describe('people grid', () => { expect(screen.getByText(/A person’s days apply to this standup only/)).toBeInTheDocument(); }); + it('shows Reports to only when given the managers, as on Settings but not Create standup (spec 009 criterion 1)', () => { + const { rerender } = render( {}} />); + expect(screen.queryByRole('columnheader', { name: 'Reports to' })).not.toBeInTheDocument(); + rerender( {}} />); + expect(screen.getByRole('columnheader', { name: 'Reports to' })).toBeInTheDocument(); + expect(screen.getByLabelText('Bob reports to')).toHaveValue(''); + }); + it('shows Saved, or the error, beside the row that changed (criterion 13)', () => { const { rerender } = render( {}} />); expect(within(screen.getAllByRole('row')[2]!).getByRole('status')).toHaveTextContent('✓ Saved'); diff --git a/web/src/pages/setup/create.tsx b/web/src/pages/setup/create.tsx index bfe3d48..ab3576e 100644 --- a/web/src/pages/setup/create.tsx +++ b/web/src/pages/setup/create.tsx @@ -134,7 +134,7 @@ export function CreateStandupPage() { if ('onStandup' in c) { set({ people: c.onStandup ? [...form.people, { userName: row.userName, displayName: row.displayName, mandatory: true, week: null }] : form.people.filter((m) => m.userName !== row.userName) }); } else if ('mandatory' in c) update({ mandatory: c.mandatory }); - else update({ week: c.week === 'reset' ? null : c.week }); + else if ('week' in c) update({ week: c.week === 'reset' ? null : c.week }); }; // A space that already has a standup was usually set up with `setup` in Chat; point at it before a duplicate is made. const taken = spaces.data?.spaces.find((s) => s.name === form.spaceName)?.standups ?? []; diff --git a/web/src/pages/standup/settings.tsx b/web/src/pages/standup/settings.tsx index d5e65ac..18b596c 100644 --- a/web/src/pages/standup/settings.tsx +++ b/web/src/pages/standup/settings.tsx @@ -10,7 +10,7 @@ import { CopyRow } from '../../components/copy-row'; import { api, type Person } from '../../lib/api'; import { ago, shortDate } from '../../lib/format'; import { useAccess } from '../../lib/access'; -import { useArchive, useDeleteStandup, useParticipantAdd, useParticipantPatch, useParticipantRemove, useRosterSync, useStandupMembers, useStandupPatch, type RosterSynced } from '../../lib/people'; +import { useArchive, useDeleteStandup, useManagerCandidates, useParticipantAdd, useParticipantPatch, useParticipantRemove, useRosterSync, useStandupMembers, useStandupPatch, type RosterSynced } from '../../lib/people'; import type { Weekday } from '../../lib/roster'; import { useGrants, useRoles } from '../../lib/roles'; import { useSetWeek } from '../../lib/schedule'; @@ -59,6 +59,7 @@ export function StandupSettingsPage() { const [rowStatus, setRowStatus] = useState(null); const followsGroup = !!standup.data?.roster.group; const members = useStandupMembers(id, !!standup.data && !followsGroup && may.people); + const managers = useManagerCandidates(id, may.people); useEffect(() => { if (s && !schedule) { setSchedule({ promptTime: s.schedule.promptTime, deadlineTime: s.schedule.deadlineTime, timezone: s.schedule.timezone, days: s.schedule.days, reminderMinutesBefore: String(s.schedule.reminderMinutesBefore) }); @@ -125,6 +126,7 @@ export function StandupSettingsPage() { if (c.onStandup) void inRow(row, () => pAdd.mutateAsync({ standupId: id, person, mandatory: true })); else setConfirm(person); } else if ('mandatory' in c) void inRow(row, () => pPatch.mutateAsync({ standupId: id, userName: row.userName, patch: { mandatory: c.mandatory } })); + else if ('reportsTo' in c) void inRow(row, () => pPatch.mutateAsync({ standupId: id, userName: row.userName, patch: { reportingManager: c.reportsTo } })); else void inRow(row, () => setWeek.mutateAsync({ userName: row.userName, workingDays: c.week })); }; return ( @@ -185,7 +187,7 @@ export function StandupSettingsPage() { ) : null} {may.people ? ( <> - +