Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/guide/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<the standup's name>" }` 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 |
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions docs/guide/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/guide/schedules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/guide/web-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions specs/009-reporting-managers.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions src/api/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,27 +227,33 @@ 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<string>();
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) {
api.post(`/requests/:id/${decision}`, async (req, res) => {
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;
}
Expand Down
1 change: 1 addition & 0 deletions src/api/spec/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
{
Expand Down
25 changes: 23 additions & 2 deletions src/api/spec/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.');
Expand Down
Loading
Loading