From b3f2a51701800166089e9946c58cbed1ba8bd30b Mon Sep 17 00:00:00 2001 From: shreyas Date: Mon, 26 Oct 2026 14:10:34 +0530 Subject: [PATCH] Add reporting managers who can approve their reports' time off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the approval time-off policy, a day-off request could only be decided by workspace admins and the standup's managers. This adds a per-team reporting manager — set on the standup's People view — who can approve their reports' days off without being made a manager of the standup. - participants gain reporting_manager columns (migration 21, both dialects) - the time-off request card also DMs the reporting manager, days off only - decide() authorises a reporting manager for their reports' days off - GET /requests widens to include a caller's reports' pending requests - web: a "Reports to" picker on each person's row, from the managers pool Spec: specs/008-reporting-managers.md --- specs/008-reporting-managers.md | 159 +++++++++++++++++++ src/api/schedule.ts | 23 ++- src/api/spec/routes.ts | 1 + src/api/spec/schemas.ts | 30 +++- src/api/standups.ts | 29 ++++ src/api/views.ts | 2 + src/core/schedule.ts | 19 ++- src/core/types.ts | 3 + src/db/repo.ts | 71 +++++++++ tests/api-standups.test.ts | 1 + tests/api.test.ts | 6 +- tests/migrations.test.ts | 10 +- tests/reporting-managers.test.ts | 221 +++++++++++++++++++++++++++ web/src/components/people-matrix.tsx | 27 +++- web/src/lib/people.ts | 7 +- web/src/pages/setup/create.tsx | 5 +- web/src/pages/standup/settings.tsx | 6 +- 17 files changed, 594 insertions(+), 26 deletions(-) create mode 100644 specs/008-reporting-managers.md create mode 100644 tests/reporting-managers.test.ts diff --git a/specs/008-reporting-managers.md b/specs/008-reporting-managers.md new file mode 100644 index 0000000..3e79a12 --- /dev/null +++ b/specs/008-reporting-managers.md @@ -0,0 +1,159 @@ +# 008: Reporting managers can approve their reports' time off + +| | | +| --- | --- | +| Status | Approved for local build (maintainer approval still required to upstream) | +| Size | Cross-cutting (migration, API, web app, permissions) | +| Figma | None — no access. UI validated by local deploy. Blocks an upstream PR until frames exist | +| PR | — | + +## Problem + +Time off under the `approval` policy can only be decided by workspace +admins and the standup's managers (`schedules.manage`). The person who +should sign off a day away is often a line manager who isn't a manager of +that team — so to let them approve, you have to make them a full manager of +the standup, handing them its whole configuration. There is also no record +of who a person reports to on a given team. We want a per-team reporting +manager, chosen from people who already manage somewhere, who can approve +their reports' days off without being made a manager of this standup. + +## User stories + +- As an admin or standup manager, I want to set who a person reports to on + this team, so the right person signs off their days off. +- As a reporting manager, I want my reports' off-day requests to reach me + and let me approve or decline them, in Chat or the web app, without being + a manager of the standup. +- As someone on two teams, I want the right manager on each team to be able + to approve my leave, since I answer to different people per team. + +## Acceptance criteria + +Assigning a reporting manager (per team) + +1. Given someone with `people.manage` on a standup, when they set a + participant's reporting manager on that standup's team view, then it is + saved against that person on that standup and shown on the row. +2. The reporting manager can only be someone who is **assigned as a manager** + on at least one standup. The picker offers only those people; choosing + anyone else is refused: "A reporting manager must already be a manager on + a standup." +3. A person cannot be their own reporting manager on a standup: the save is + refused with "Someone can't report to themselves." +4. Clearing the field removes it; that person then has no reporting manager + on that standup and the approver set reverts to the team's managers and + admins. +5. The reporting manager is per standup: the same person on another standup + can have a different reporting manager, set independently. + +Approving as a reporting manager + +6. Given a standup on the `approval` policy, when a person requests a day + **off**, then the pending-request card is DMed to that standup's managers + and admins **and** to that person's reporting manager on that standup, if + set and not already among them. +7. A **working** request (back-to-work on an off day) is not sent to the + reporting manager; it goes to the standup's managers and admins only, as + today. +8. When any eligible approver — a manager/admin of an affected standup **or** + that person's reporting manager on an affected standup — approves or + declines an off-day request, the decision applies exactly as a manager's + does today across all the person's standups: the run is updated, the + person is told, and the webhook fires. **The first decision wins** (others + see "already decided"). +9. A reporting manager sees their reports' pending off-day requests on Team › + Requests for the standups they are the reporting manager on, even if they + do not manage those standups. +10. A reporting manager who is also the requester cannot decide their own + request: "Your own requests go to your managers." (Unchanged behaviour.) +11. The reporting manager relationship only grants approval under the + `approval` policy. It creates no new approval path on `self` or + `managers` standups. + +No regression to the downstream flow + +12. An approval made by a reporting manager marks the person away for that + run, and their Google Calendar out-of-office, Workspace status and the + "off" chart update exactly as an admin's approval does today — the same + `applyToRuns` → `LeavePresence` path, no new sync code. + +## Non-goals + +- **Half-day or partial-day leave.** Dropped; leave stays whole-day. +- A reporting manager **setting or changing** a report's schedule directly. + They approve requests; marking someone away stays with managers/admins. +- A reporting manager deciding **working** requests (criterion 7). +- **Each-team-must-approve.** One approval settles the request for all of a + person's teams, as today. No partial-approval state. +- Letting anyone who is not already a manager be a reporting manager. +- Deriving the reporting manager from the Google Directory org chart or SCIM. +- A workspace-wide "one boss" per person, approval chains, or delegates. +- MCP changes. + +## Design impact + +| Area | Change | +| --- | --- | +| Web app | On each standup's team view, a **Reporting manager** control on every participant row — a picker listing the workspace's managers, with a clear option, behind `people.manage`. Team › Requests also lists off-day requests where the caller is the reporting manager | +| Chat cards and dialogs | None new. The existing time-off request card (`sendTimeOffRequest`) is additionally DMed to the reporting manager, for off-day requests only | +| API and OpenAPI | `PATCH /standups/:id/participants/:userName` gains a `reportingManager` field (`users/…` or `null`); the participant payload returns it. `GET /requests` widens its result to include off-day requests where the caller is a reporting manager; `POST /requests/:id/{approve,decline}` accepts a reporting manager as decider | +| MCP | None | +| Database | New migration, both dialects: two columns on `participants` — `reporting_manager` (the manager's `users/…`, nullable) and `reporting_manager_name` (their display name) | +| Permissions | None new. Assigning uses existing `people.manage` on the standup; deciding as a reporting manager is granted by the relationship, alongside `schedules.manage` | +| Docs | `roles.md` and/or `schedules.md` (who can approve), `web-app.md` (team row), `api.md` (participant field + widened requests) | +| Website | None | +| Google APIs and scopes | None | + +How it works: + +- The reporting manager is a per-participant attribute, so it lives as two + columns on `participants` — the table already keyed by `(standup_id, + user_name)`. Same person on two standups → two independent values. This + mirrors how `standup_admins` and the `mandatory` flag already work. +- Candidates are people assigned as a manager on at least one standup + (`standup_admins`), gathered across the tenant. The web picker shows that + set; the API validates the chosen `reportingManager` is in it. +- Assignment reuses the existing participant route + (`PATCH /standups/:id/participants/:userName`), which already needs + `people.manage` — no new route, no new permission. +- `ScheduleService.setOverride`, when an **off** request goes `pending`, + already DMs each governed standup's admins; it additionally reads that + person's `reporting_manager` on each such standup and DMs them the same + card, de-duplicated against admins already told. A `working` pending + request skips the reporting manager. +- `ScheduleService.decide` gains one authority branch: alongside + `actor.admin` and `schedules.manage`, the actor may decide an off-day + request if they are the requester's reporting manager on any of the + requester's standups. The self-request guard (criterion 10) is already + there and still applies, and "first decision wins" is the existing + behaviour — one approval clears the person's leave across every team. +- `GET /requests` builds its `names` list from the standups the caller + manages; it now also adds the people who report to the caller on any + standup, so a reporting manager who manages no standup still sees their + reports' off-day requests. +- Approval runs the existing path unchanged: `decide` → `applyToRuns` → + the next `LeavePresence.sweep` writes the calendar and status, and the run + shows the person off. Criterion 12 is a no-new-code assertion, covered by + a test that a reporting-manager approval leaves the same run/leave state + as an admin approval. +- Failure cases: a reporting manager who stops being a manager everywhere + keeps their existing assignments working (they are still a valid decider + for those), but can no longer be picked afresh. One removed from the + workspace leaves a dangling `reporting_manager`; `decide` finds no match + and the normal approvers still apply. + +## Open questions + +None. Decided for the local build on 25 Oct 2026: + +- The reporting manager is **per team** (per standup), stored on the + participant row. +- Candidates are **existing roster members already assigned as managers**. +- The reporting manager approves **off-day requests only**, not working + requests. +- **One approval** settles an off-day request across all of a person's + teams (first decision wins), as today. +- No Figma access: the UI is **validated by local deploy**. This is a + deliberate deviation from the Definition of Done and must be resolved with + frames before any upstream PR. diff --git a/src/api/schedule.ts b/src/api/schedule.ts index e3fc256..e02acb9 100644 --- a/src/api/schedule.ts +++ b/src/api/schedule.ts @@ -230,16 +230,23 @@ export function registerScheduleRoutes(api: Router, ctx: ApiContext): void { api.get('/requests', async (req, res) => { if (!schedule(res)) return; const p = principalOf(req); - if (!canSomewhere(p.access, 'schedules.manage')) { + const me = p.user?.userName ?? null; + const reports = new Set(me ? await repo.listReportsOf(me) : []); + if (!canSomewhere(p.access, 'schedules.manage') && reports.size === 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); + const workspaceWide = allowed(p, 'schedules.manage'); + const managed = new Set(); + if (!workspaceWide) { + for (const id of standupsWith(p.access, 'schedules.manage')) for (const x of await repo.listParticipants(id)) managed.add(x.userName); } - res.json({ requests: (await repo.listPendingOverrides(names)).map(overrideView) }); + const names = workspaceWide ? undefined : [...new Set([...managed, ...reports])]; + const requests = (await repo.listPendingOverrides(names)) + // A reporting manager who doesn't manage the person only sees their days off. + .filter((o) => workspaceWide || managed.has(o.userName) || !o.working) + .map(overrideView); + res.json({ requests }); }); for (const decision of ['approve', 'decline'] as const) { @@ -247,7 +254,9 @@ 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')) { + const me = p.user?.userName ?? null; + const hasReports = me ? (await repo.listReportsOf(me)).length > 0 : false; + if (!canSomewhere(p.access, 'schedules.manage') && !hasReports) { 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 5ef1993..15ae716 100644 --- a/src/api/spec/routes.ts +++ b/src/api/spec/routes.ts @@ -93,6 +93,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 set 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 f172149..f04b11b 100644 --- a/src/api/spec/schemas.ts +++ b/src/api/spec/schemas.ts @@ -109,6 +109,8 @@ export const StandupDetail = named( workingDays: z.string().nullable(), otherStandups: count, source: z.enum(['manual', 'group']), + reportingManager: z.string().nullable().describe('Who approves this person’s days off on this standup, besides its managers; null when none is set.'), + reportingManagerName: z.string().nullable(), }), ), admins: z.array(Person), @@ -225,7 +227,21 @@ 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(), + }), +); + +export const ManagerCandidates = named( + 'ManagerCandidates', + z.strictObject({ managers: z.array(Person) }), + 'People assigned as a manager on some standup — the pool a reporting manager is picked from.', ); // --- blockers and people @@ -540,7 +556,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('Who approves this person’s days off; must already be a manager on a standup. 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..8c40963 100644 --- a/src/api/standups.ts +++ b/src/api/standups.ts @@ -176,6 +176,13 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void { res.json({ secret: ctx.webhookSecret(standup.id) }); }); + // The pool a reporting manager is picked from: anyone assigned as a manager on some standup. + 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) }); + }); + api.post('/standups/:id/participants', async (req, res) => { const standup = await loadStandup(ctx, req, res, { need: 'people.manage' }); if (!standup) return; @@ -216,6 +223,26 @@ export function registerStandupRoutes(api: Router, ctx: ApiContext): void { return; } } + if (body.reportingManager !== undefined) { + const rm = body.reportingManager; + if (rm !== null && typeof rm !== 'string') { + apiError(res, 400, 'invalid', 'reportingManager must be a userName or null.', 'reportingManager'); + return; + } + if (rm === null) { + await repo.setReportingManager(standup.id, userName, null); + } else if (rm === userName) { + apiError(res, 400, 'invalid', "Someone can't report to themselves.", 'reportingManager'); + return; + } else { + const candidate = (await repo.listManagerCandidates(standup.tenantId)).find((c) => c.userName === rm); + if (!candidate) { + apiError(res, 400, 'invalid', 'A reporting manager must already be a manager on a standup.', 'reportingManager'); + return; + } + await repo.setReportingManager(standup.id, userName, candidate); + } + } 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 +261,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..a836499 100644 --- a/src/core/schedule.ts +++ b/src/core/schedule.ts @@ -380,6 +380,18 @@ export class ScheduleService { this.log(`time-off request to ${admin.userName} failed: ${err}`); } } + // An off-day request also reaches the person's reporting manager on this standup. + if (!working) { + const rm = await this.repo.getReportingManager(standup.id, target.userName); + if (rm && rm.userName !== target.userName && !told.has(rm.userName)) { + told.add(rm.userName); + try { + await this.adapter.sendTimeOffRequest(rm.userName, { ...request, standup }); + } catch (err) { + this.log(`time-off request to ${rm.userName} failed: ${err}`); + } + } + } } 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]}\`.` }; @@ -458,15 +470,18 @@ 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)); + // A reporting manager can decide a report's day off, even without managing the standup. + const reportsToActor = !working && (await this.repo.isReportingManagerOf(actor.userName, person.userName)); + if (!manages && !reportsToActor) 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..45f3a68 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 approves this person's days off on this standup, besides its managers; null = none set. */ + reportingManager: string | null; + reportingManagerName: string | null; active: boolean; } diff --git a/src/db/repo.ts b/src/db/repo.ts index 4346028..c152054 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -468,6 +468,12 @@ CREATE TABLE edit_requests ( ); CREATE INDEX idx_edit_requests_status ON edit_requests(status); ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; +`, + // 21: who each person reports to on a standup — a reporting manager who can + // approve their days off without being a manager of the standup + ` +ALTER TABLE participants ADD COLUMN reporting_manager TEXT; +ALTER TABLE participants ADD COLUMN reporting_manager_name TEXT; `, ]; @@ -813,6 +819,12 @@ CREATE TABLE edit_requests ( ); CREATE INDEX idx_edit_requests_status ON edit_requests(status); ALTER TABLE submissions ADD COLUMN edit_approved_by TEXT; +`, + // 21: who each person reports to on a standup — a reporting manager who can + // approve their days off without being a manager of the standup + ` +ALTER TABLE participants ADD COLUMN reporting_manager TEXT; +ALTER TABLE participants ADD COLUMN reporting_manager_name TEXT; `, ]; @@ -896,6 +908,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, }; } @@ -1220,6 +1234,63 @@ export class Repo { ); } + // --- reporting managers --- + + /** Sets (or clears, with null) who approves this participant's days off on this standup. */ + 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 = ?', + [manager?.userName ?? null, manager?.displayName ?? null, standupId, userName], + ); + } + + /** Who this participant reports to on this standup, or null. */ + async getReportingManager(standupId: number, userName: string): Promise<{ userName: string; displayName: string } | null> { + const row = await this.db.get( + 'SELECT reporting_manager, reporting_manager_name FROM participants WHERE standup_id = ? AND user_name = ? AND active = 1', + [standupId, userName], + ); + return row?.reporting_manager ? { userName: row.reporting_manager, displayName: row.reporting_manager_name } : null; + } + + /** Everyone assigned as a manager on any standup in the tenant — the pool 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 display_name`, + [tenantId], + ); + return rows.map((r: any) => ({ userName: r.user_name, displayName: r.display_name })); + } + + /** Whether someone is a manager on any standup in the tenant — validates a reporting-manager pick. */ + async isManagerAnywhere(tenantId: string, userName: string): Promise { + return !!(await this.db.get( + `SELECT 1 FROM standup_admins a JOIN standups s ON s.id = a.standup_id + WHERE s.tenant_id = ? AND a.user_name = ? LIMIT 1`, + [tenantId, userName], + )); + } + + /** The user names of everyone who reports to this manager on any active standup. */ + async listReportsOf(managerUserName: string): Promise { + const rows = await this.db.all( + 'SELECT DISTINCT user_name FROM participants WHERE reporting_manager = ? AND active = 1', + [managerUserName], + ); + return rows.map((r: any) => r.user_name); + } + + /** Whether someone is the reporting manager of this person on any active standup. */ + async isReportingManagerOf(managerUserName: string, personUserName: string): Promise { + return !!(await this.db.get( + 'SELECT 1 FROM participants WHERE user_name = ? AND reporting_manager = ? AND active = 1 LIMIT 1', + [personUserName, managerUserName], + )); + } + // --- 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..a455ca8 100644 --- a/tests/api-standups.test.ts +++ b/tests/api-standups.test.ts @@ -219,6 +219,7 @@ describe('api: roster', () => { 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, + 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 c6be965..c7058b7 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(20); - expect(POSTGRES_MIGRATIONS).toHaveLength(20); + expect(SQLITE_MIGRATIONS).toHaveLength(21); + expect(POSTGRES_MIGRATIONS).toHaveLength(21); 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(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); 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(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); 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(20); + expect((check.query('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(21); check.close(); }); }); diff --git a/tests/reporting-managers.test.ts b/tests/reporting-managers.test.ts new file mode 100644 index 0000000..2625627 --- /dev/null +++ b/tests/reporting-managers.test.ts @@ -0,0 +1,221 @@ +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { EventRouter } from '../src/adapters/gchat/events.js'; +import { newSession, sealSession } from '../src/auth/session.js'; +import { createServer } from '../src/server.js'; +import { ScheduleService } from '../src/core/schedule.js'; +import { makeStack, seedStandup, TENANT } from './helpers.js'; + +const alice = { userName: 'users/alice', displayName: 'Alice' }; +const bob = { userName: 'users/bob', displayName: 'Bob' }; +const carol = { userName: 'users/carol', displayName: 'Carol' }; +const admin = { userName: 'users/admin', displayName: 'Admin' }; +const selfActor = (m: { userName: string; displayName: string }) => ({ ...m, admin: false }); + +// --- the reporting-manager data model lives on the participant row --- + +describe('repo: reporting managers', () => { + it('sets, reads and clears who a participant reports to on a standup', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + expect(await repo.getReportingManager(standup.id, 'users/bob')).toBeNull(); + await repo.setReportingManager(standup.id, 'users/bob', carol); + expect(await repo.getReportingManager(standup.id, 'users/bob')).toEqual({ userName: 'users/carol', displayName: 'Carol' }); + expect((await repo.listParticipants(standup.id)).find((p) => p.userName === 'users/bob')).toMatchObject({ reportingManager: 'users/carol', reportingManagerName: 'Carol' }); + await repo.setReportingManager(standup.id, 'users/bob', null); + expect(await repo.getReportingManager(standup.id, 'users/bob')).toBeNull(); + }); + + it('offers managers across the tenant as candidates, and tells reports from managers', async () => { + const { repo } = await makeStack(); + const a = await seedStandup(repo, { spaceName: 'spaces/a' }); + const b = await seedStandup(repo, { spaceName: 'spaces/b' }); + await repo.addAdmin(a.id, 'users/alice', 'Alice'); + await repo.addAdmin(b.id, 'users/alice', 'Alice'); // same person, both standups — listed once + await repo.addAdmin(b.id, 'users/carol', 'Carol'); + expect(await repo.listManagerCandidates(TENANT)).toEqual([ + { userName: 'users/alice', displayName: 'Alice' }, + { userName: 'users/carol', displayName: 'Carol' }, + ]); + expect(await repo.isManagerAnywhere(TENANT, 'users/alice')).toBe(true); + expect(await repo.isManagerAnywhere(TENANT, 'users/bob')).toBe(false); + + await repo.setReportingManager(a.id, 'users/bob', carol); + await repo.setReportingManager(b.id, 'users/bob', carol); + expect(await repo.listReportsOf('users/carol')).toEqual(['users/bob']); + expect(await repo.isReportingManagerOf('users/carol', 'users/bob')).toBe(true); + expect(await repo.isReportingManagerOf('users/alice', 'users/bob')).toBe(false); + }); +}); + +// --- the approval flow: a reporting manager is an extra approver for days off --- + +async function coreStack() { + const s = await makeStack(); + const standup = await seedStandup(s.repo); + await s.repo.addAdmin(standup.id, admin.userName, admin.displayName); + await s.repo.updateStandup(standup.id, { timeOffPolicy: 'approval' }); + s.clock.set('2026-06-10T10:00'); // Wednesday + return { ...s, standup }; +} + +describe('reporting managers in the approval flow', () => { + it('sends a day-off request to the reporting manager and lets them approve it, like a manager (criteria 6, 8, 10)', async () => { + const { schedule, repo, adapter, scheduler, standup } = await coreStack(); + await scheduler.tick(); + const run = (await repo.getRun(standup.id, '2026-06-10'))!; + await repo.setReportingManager(standup.id, 'users/bob', carol); + + const r = await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + expect(r).toMatchObject({ ok: true, status: 'pending' }); + const toCarol = adapter.dms.filter((d) => d.kind === 'timeOffRequest' && d.userName === 'users/carol'); + expect(toCarol).toHaveLength(1); + expect(adapter.dms.some((d) => d.kind === 'timeOffRequest' && d.userName === 'users/admin')).toBe(true); + + // Carol manages no standup, but she is Bob's reporting manager, so she can decide his day off. + const approved = await schedule.decide(toCarol[0]!.request!.ids, true, selfActor(carol), null); + expect(approved).toMatchObject({ ok: true }); + expect((await repo.getOverride('users/bob', '2026-06-11'))!.status).toBe('active'); + expect(adapter.dms.at(-1)).toMatchObject({ userName: 'users/bob', text: expect.stringContaining('approved your time off request') }); + + // Same downstream state as an admin approval would leave (criterion 10). + await schedule.setOverride({ target: bob, dates: ['2026-06-10'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + const pendingToday = (await repo.listPendingOverrides(['users/bob'])).map((o) => o.id); + await schedule.decide(pendingToday, true, selfActor(carol), null); + expect((await repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/bob')).toMatchObject({ awayReason: 'day_off' }); + }); + + it('does not involve the reporting manager in a working request (criteria 7, 9)', async () => { + const { schedule, repo, adapter, standup } = await coreStack(); + await repo.setReportingManager(standup.id, 'users/bob', carol); + // Bob asks to work a day he is normally off. + await repo.setWorkingDays(standup.id, 'users/bob', 'mon,tue'); // Wed/Thu now off for Bob + const r = await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: true, reason: 'release', actor: selfActor(bob), channel: 'chat' }); + expect(r).toMatchObject({ ok: true, status: 'pending' }); + expect(adapter.dms.some((d) => d.kind === 'timeOffRequest' && d.userName === 'users/carol')).toBe(false); + // And she cannot decide a working request. + const ids = (await repo.listPendingOverrides(['users/bob'])).map((o) => o.id); + expect(await schedule.decide(ids, true, selfActor(carol), null)).toMatchObject({ ok: false, message: "Only Bob's managers can decide this." }); + }); + + it('sends the reporting manager just one card when they are also a standup manager', async () => { + const { schedule, repo, adapter, standup } = await coreStack(); + await repo.setReportingManager(standup.id, 'users/bob', admin); // admin is already a manager + await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + expect(adapter.dms.filter((d) => d.kind === 'timeOffRequest' && d.userName === 'users/admin')).toHaveLength(1); + }); + + it('skips a reporting manager that is the requester themselves', async () => { + const { schedule, repo, adapter, standup } = await coreStack(); + await repo.setReportingManager(standup.id, 'users/bob', bob); // self (can't happen via the API, guarded here) + await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + expect(adapter.dms.filter((d) => d.kind === 'timeOffRequest' && d.userName === 'users/bob')).toHaveLength(0); + }); + + it('proceeds when the reporting manager cannot be DMed', async () => { + const { schedule, repo, adapter, standup } = await coreStack(); + await repo.setReportingManager(standup.id, 'users/bob', carol); + adapter.sendTimeOffRequest = async (userName: string) => { + if (userName === 'users/carol') throw new Error('cannot DM'); + }; + const r = await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + expect(r).toMatchObject({ ok: true, status: 'pending' }); // the request still stands + expect((await repo.listPendingOverrides(['users/bob'])).map((o) => o.date)).toEqual(['2026-06-11']); + }); +}); + +describe('setting a whole week under an approval policy', () => { + it('is refused across standups when any needs approval', async () => { + const { repo, schedule } = await makeStack(); + const a = await seedStandup(repo, { spaceName: 'spaces/a' }); + const b = await seedStandup(repo, { spaceName: 'spaces/b' }); + await repo.updateStandup(b.id, { timeOffPolicy: 'approval' }); + const r = await schedule.setWorkingDays(alice, 'mon-thu', selfActor(alice), 'api', 'all'); + expect(r).toMatchObject({ ok: false, message: expect.stringContaining('Only your managers can change your week here') }); + }); +}); + +// --- the API: assigning a reporting manager, and seeing/deciding requests as one --- + +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?.()); + +async function server() { + const stack = await makeStack(); + const standup = await seedStandup(stack.repo); + await stack.repo.addAdmin(standup.id, 'users/alice', 'Alice'); // a manager, so a candidate + await stack.repo.updateStandup(standup.id, { timeOffPolicy: 'approval' }); + stack.clock.set('2026-06-10T10:00'); + const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); + const app = createServer({ + router, scheduler: stack.scheduler, adapter: stack.adapter, blockers: stack.blockers, + repo: stack.repo, settings: stack.settings, dashboardToken: OPERATOR, skipVerification: true, + secretKey: SECRET, service: stack.service, schedule: stack.schedule, now: stack.clock.now, + }); + const srv = app.listen(0); + close = () => srv.close(); + const url = `http://127.0.0.1:${(srv.address() as AddressInfo).port}`; + 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}/api/v1${path}`, { ...init, headers: { authorization: `Bearer ${OPERATOR}`, 'content-type': 'application/json', ...(init.headers as Record) } }); + const as = (cookie: string, path: string, init: RequestInit = {}) => + fetch(`${url}/api/v1${path}`, { ...init, headers: { cookie, ...CSRF, ...(init.headers as Record) } }); + return { ...stack, standup, url, op, as, cookieFor }; +} + +describe('API: assigning a reporting manager', () => { + it('sets, shows, validates and clears a participant reporting manager (criteria 1–4)', async () => { + const { op, repo, standup } = await server(); + const patch = (who: string, body: unknown) => op(`/standups/${standup.id}/participants/${encodeURIComponent(who)}`, { method: 'PATCH', body: JSON.stringify(body) }); + + // candidates are the standup's managers + const cands = await (await op(`/standups/${standup.id}/manager-candidates`)).json() as any; + expect(cands.managers).toEqual([{ userName: 'users/alice', displayName: 'Alice' }]); + + // criterion 1: set, and it shows on the row and in the detail + const set = await (await patch('users/bob', { reportingManager: 'users/alice' })).json() as any; + expect(set).toMatchObject({ userName: 'users/bob', reportingManager: 'users/alice', reportingManagerName: 'Alice' }); + const detail = await (await op(`/standups/${standup.id}`)).json() as any; + expect(detail.participants.find((p: any) => p.userName === 'users/bob')).toMatchObject({ reportingManager: 'users/alice', reportingManagerName: 'Alice' }); + + // criterion 3: can't report to yourself + expect((await (await patch('users/alice', { reportingManager: 'users/alice' })).json() as any).error).toMatchObject({ field: 'reportingManager', message: "Someone can't report to themselves." }); + // criterion 2: must already be a manager + expect((await (await patch('users/bob', { reportingManager: 'users/carol' })).json() as any).error.message).toContain('must already be a manager'); + // a bad type is refused + expect((await (await patch('users/bob', { reportingManager: 7 })).json() as any).error.field).toBe('reportingManager'); + // criterion 4: clear it + expect(await (await patch('users/bob', { reportingManager: null })).json() as any).toMatchObject({ reportingManager: null, reportingManagerName: null }); + }); +}); + +describe('API: seeing and deciding requests as a reporting manager', () => { + it('a reporting manager who manages no standup sees and approves their reports’ days off (criteria 6, 9)', async () => { + const { as, op, repo, schedule, standup, cookieFor } = await server(); + await repo.setReportingManager(standup.id, 'users/bob', carol); // Carol is not a manager, only Bob's RM + await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + + const carolCookie = cookieFor('carol', 'Carol'); + const seen = await (await as(carolCookie, '/requests')).json() as any; + expect(seen.requests.map((r: any) => [r.userName, r.date])).toEqual([['users/bob', '2026-06-11']]); + + const id = seen.requests[0].id; + const approve = await as(carolCookie, `/requests/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }); + expect(approve.status).toBe(200); + expect((await repo.getOverride('users/bob', '2026-06-11'))!.status).toBe('active'); + + // Someone who is neither a manager nor a reporting manager is refused. + expect((await as(cookieFor('dave', 'Dave'), '/requests')).status).toBe(403); + }); + + it('a standup manager still sees every request of the people they manage, days off or working', async () => { + const { as, repo, schedule, standup, cookieFor } = await server(); + await schedule.setOverride({ target: bob, dates: ['2026-06-11'], working: false, reason: 'sick', actor: selfActor(bob), channel: 'chat' }); + // Alice manages the standup (added as admin in server()), so she sees Bob's request without being his RM. + const seen = await (await as(cookieFor('alice', 'Alice'), '/requests')).json() as any; + expect(seen.requests.map((r: any) => r.userName)).toContain('users/bob'); + }); +}); diff --git a/web/src/components/people-matrix.tsx b/web/src/components/people-matrix.tsx index a7b2302..676a6a0 100644 --- a/web/src/components/people-matrix.tsx +++ b/web/src/components/people-matrix.tsx @@ -6,10 +6,12 @@ export interface MatrixRow { onStandup: boolean; mandatory: boolean; workingDays: string | null; + reportingManager?: string | null; + reportingManagerName?: string | null; badges?: string[]; } -export type MatrixChange = { onStandup: boolean } | { mandatory: boolean } | { week: string }; +export type MatrixChange = { onStandup: boolean } | { mandatory: boolean } | { week: string } | { reportingManager: 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, reportsTo = true, managers = [], status = null, onChange }: { rows: MatrixRow[]; standupDays: readonly Weekday[]; busy?: boolean; weeks?: boolean; reportsTo?: boolean; managers?: { userName: string; displayName: string }[]; status?: RowStatus | 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 + {reportsTo ? Reports to : null} {WEEK.map((d) => ( {DAY_LABEL[d]} @@ -73,6 +76,26 @@ export function PeopleMatrix({ rows, standupDays, busy = false, weeks = true, st onChange(row, { mandatory: e.target.checked })} /> + {reportsTo ? ( + + + + ) : null} {WEEK.map((d) => ( , 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), }); } +/** People who can be set as a reporting manager — anyone who manages a standup. */ +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 useParticipantRemove() { const client = useQueryClient(); return useMutation({ diff --git a/web/src/pages/setup/create.tsx b/web/src/pages/setup/create.tsx index bfe3d48..c214caa 100644 --- a/web/src/pages/setup/create.tsx +++ b/web/src/pages/setup/create.tsx @@ -134,7 +134,8 @@ 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 }); + // Reporting managers are set later, in the standup's settings, once it exists. }; // 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 ?? []; @@ -207,7 +208,7 @@ export function CreateStandupPage() { ) : members.isError ? ( Could not list members: {members.error.message} ) : ( - + )} diff --git a/web/src/pages/standup/settings.tsx b/web/src/pages/standup/settings.tsx index d5e65ac..c050bb9 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'; @@ -42,6 +42,7 @@ export function StandupSettingsPage() { const pPatch = useParticipantPatch(); const pRemove = useParticipantRemove(); const pAdd = useParticipantAdd(); + const managerCandidates = useManagerCandidates(id, may.people); const verifyWebhook = useVerifyMutation('webhook'); const secret = useMutation({ mutationFn: () => api<{ secret: string }>(`/standups/${id}/webhook/secret`) }); const s = standup.data; @@ -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 ('reportingManager' in c) void inRow(row, () => pPatch.mutateAsync({ standupId: id, userName: row.userName, patch: { reportingManager: c.reportingManager } })); else void inRow(row, () => setWeek.mutateAsync({ userName: row.userName, workingDays: c.week })); }; return ( @@ -238,7 +240,7 @@ export function StandupSettingsPage() { ) : null} {members.isError ?
Could not list the space’s members: {members.error.message}. Showing the current roster only.
: null} - + {roles.isError || grants.isError ?
Could not load roles: {(roles.error ?? grants.error)?.message}
: null}