* feat(plainspace): create tasks directly in a Plainspace-backed project
Adding a top-level task to an SP project that has a bound PLAINSPACE issue
provider now creates the task in the Plainspace space so the team sees it,
symmetric with the existing auto-import. Wires into the generic
autoCreateIssueOnTaskAdd$ pipeline:
- PlainspaceApiService.createTask$ -> POST /api/integration/tasks { spaceId,
title }; errors propagate so a failed add surfaces a snack.
- PlainspaceSyncAdapterService.createIssue links the returned SPTask id and
seeds the two-way-sync baseline (no issueNumber, so no '#123' title prefix).
- _hasAutoCreateEnabled recognises the native PLAINSPACE key (no opt-in flag;
the bound provider is the opt-in).
Requires a new PAT-authed server route POST /api/integration/tasks (documented
in docs/plainspace-api-extension-plan.md 4c); inert until that ships.
* fix(plainspace): only auto-create tasks once the provider is configured
Multi-review follow-ups to the create-task feature:
- _hasAutoCreateEnabled now requires a bound Plainspace provider (spaceId +
token), not just isEnabled. selectEnabledIssueProviders filters on the flag
only, so a mid-connect provider (spaceId/token still null) previously POSTed an
invalid create and error-snacked on every task add; now it skips silently until
configured. Adds a covering spec.
- Drop the unnecessary 'as unknown as' double cast in createIssue.
- Drop the unnecessary 'as any' on the valid 'PLAINSPACE' key in the effect spec.
25 KiB
Plainspace API extension plan — to enable the Super Productivity integration
Audience: the Plainspace server team (Johannesjo/spaces, packages/server).
Author: drafted from the SP side while wiring PR #8424 (SP ↔ Plainspace).
Status: proposal — three small PAT-authed endpoints + one optional polling
enhancement. No breaking changes; all additive.
Why this exists. SP's Plainspace integration (PR #8424) was built against an assumed API and is currently mock-backed. Connecting it to the real backend revealed that the live
/api/integrationsurface supports only read my assigned tasks and toggle done — which covers SP's "import my tasks + sync completion" path, but not the two features in the PR's final commits: the claim pool (claim an unassigned task) and Share on Plainspace (create a space from SP). Those need new server endpoints. This document specifies them, grounded in the existing code so they drop into the current patterns.
1. What the SP client needs, and what exists today
| SP feature | SP API call (client) | Real endpoint today | Gap |
|---|---|---|---|
| Verify token / identity | getMe$ |
GET /api/integration/me |
✅ exists |
| Import my assigned tasks | getMyTasks$ |
GET /api/integration/tasks |
✅ exists (filter client-side by space) |
| Refresh one task | getById$ |
GET /api/integration/tasks/:id |
✅ exists |
| Push completion back | done write-back | PATCH /api/integration/tasks/:id |
✅ exists |
| Push scheduled time | patchTask$ { scheduledAt } |
PATCH /api/integration/tasks/:id |
⚠️ extend (accept scheduledAt) |
| Read scheduled time | getMyTasks$/getById$ → scheduledAt/isRecurring |
serializeSPTask |
⚠️ extend (expose scheduledAt/isRecurring) |
| Claim pool (list unclaimed) | getUnclaimedTasks$ |
— | ❌ new endpoint |
| Claim a task (self-assign) | claimTask$ |
— | ❌ new endpoint |
| Share on Plainspace (create space) | createSpace$ |
— | ❌ new endpoint |
| Efficient polling | poll loop | full refetch only | ⚠️ optional: ?updatedSince= |
All three new endpoints reuse the existing apiTokenMiddleware, the
loadIntegrationScope() helper, and the SPTask DTO already defined in
integration.ts. No new auth machinery.
Recap of the existing integration model (for context)
apiTokenMiddleware resolves a pat_… bearer token to a verified email
(blind-indexed emailLookup). loadIntegrationScope(emailLookup) then finds
every members row for that email where emailVerified = true and
tosVersion = TOS_VERSION, yielding the set of projects the token may act in.
A single PAT therefore spans all of the caller's Spaces — keep that property
for the new endpoints.
2. New endpoint 1 — Claim a task (self-assign)
POST /api/integration/tasks/:taskId/claim
Authorization: Bearer pat_…
→ 200 { task: SPTask }
Assigns an unassigned, not-deleted task to the caller's member in that task's
project — the server-side of SP's "Claim" button. Mirror the assignment path in
routes/items.ts (the item.assigned branch) and the transactional pattern in
the existing PATCH /api/integration/tasks/:taskId.
Logic
-
Load scope. If empty →
404 { error: 'Task not found' }. -
Look up the item by id, restricted to
scope.projectIds,isNull(deletedAt). Not found →404. -
Resolve the caller's member for that project:
member = scope.memberByProjectId.get(item.projectId). Missing →404. -
Atomic claim (handles the race where two members claim at once — same conditional-update pattern as the verification-code claim in
projects.ts):const [claimed] = await tx .update(items) .set({ assignedTo: member.id }) .where( and( eq(items.id, taskId), eq(items.projectId, item.projectId), isNull(items.assignedTo), // only if still unclaimed isNull(items.deletedAt), ), ) .returning(); if (!claimed) return c.json({ error: 'Task already claimed' }, 409); // lost the race / already assigned -
recordActivity(tx, { action: 'item.assigned', targetType: 'item', targetId: taskId, memberId: member.id, meta: { text: item.text, assignedTo: member.id, source: 'sp' } }). -
After commit:
sseManager.broadcast(projectId, 'item.updated', { item: serializeItem(claimed), memberId: member.id })and theactivitybroadcast — exactly like the PATCH handler, so open web clients see the claim live. -
Do not enqueue an
assignmentNotificationsrow: claiming is a self-assignment, and that table already excludes self-assignments by design (see the comment onassignmentNotificationsand theassignee !== member.idguard initems.ts). Pinging yourself about a task you just claimed is noise. -
Return
{ task: serializeSPTask(claimed, list, proj, origin) }(fetchlistprojas the PATCH handler does, or inside the tx).
Status codes: 200 claimed · 409 already assigned (to anyone, incl. you) ·
404 unknown task / not a member / deleted · 401 bad token.
Design note — why claim is its own endpoint, not
PATCH …{ assignedTo }. Exposing arbitraryassignedToover a PAT would let SP assign tasks to other members, which the integration's security model deliberately forbids (a PAT acts only as its own member).claimis the one safe, self-scoped assignment: it can only ever setassignedTo = me, and only from unassigned. This matches the asymmetry indocs/plans/2026-06-02-super-productivity-integration.md("never let SP dictate assignment into a third party").
3. New endpoint 2 — List claimable (unassigned) tasks
GET /api/integration/claimable-tasks → { tasks: SPTask[] }
GET /api/integration/claimable-tasks?projectId=<uuid> (optional filter)
The read side of the claim pool: unassigned, not-done, not-deleted items in the
projects the caller is a member of. Same SPTask DTO as /tasks, so SP's mapper
is unchanged. Structurally identical to the existing GET /tasks, with the
assignment predicate inverted:
const scope = await loadIntegrationScope(emailLookup);
if (scope.memberRows.length === 0) return c.json({ tasks: [] });
const rows = await db.query.items.findMany({
where: and(
inArray(items.projectId, projectIdFilter ?? scope.projectIds),
isNull(items.assignedTo), // unclaimed
eq(items.checked, false), // not done
isNull(items.deletedAt),
),
});
// then the same list/project hydration + serializeSPTask loop as GET /tasks
?projectId=(when present) must be intersected withscope.projectIdsso it can't be used to probe foreign projects. SP passes its boundspaceIdhere to avoid over-fetching unclaimed tasks from unrelated Spaces.- Visibility is membership-based and independent of
sharingMode: members of aprivateSpace still see its unclaimed items (sharingMode gates joining, not member visibility).
Cheaper alternative if you'd rather not add a path: a query param on the existing endpoint —
GET /api/integration/tasks?scope=claimable(defaultscope=assigned). The dedicated path keeps/taskssemantics crisp and reads better client-side; either is fine. (This is also the natural place the?updatedSince=param from §6 would live.)
4. New endpoint 3 — Create a space (Share on Plainspace)
POST /api/integration/spaces
Authorization: Bearer pat_…
Body: { name: string, purpose?: string, displayName?: string }
→ 201 { project: { id, slug, name, purpose, sharingMode }, memberId }
Provisions a new Space owned by the PAT's email and returns the ids SP binds its
provider to (PlainspaceCfg.spaceId = project.id, link via project.url).
Mirror the POST /api/projects transaction in routes/projects.ts, minus the
email-code gate.
Logic
- Decrypt the PAT's email:
decryptStoredEmail(row)is already done inapiTokenMiddleware; expose it viac.get('apiTokenEmail')(the middleware sets it). Normalize withnormalizeEmail. - Validate body with a new
CreateSpaceViaTokenSchema(zod, inpackages/shared/src/validation.ts):name 1..MAX_PROJECT_NAME_LENGTH,purpose ≤ MAX_PURPOSE_LENGTH default '',displayName 1..MAX_DISPLAY_NAME_LENGTH(default to the email local-part if omitted).safeParsefailure →422withdetails: error.flatten(). - Transaction (mirrors
projects.ts):const slug = nanoid(SLUG_LENGTH); const [project] = await tx .insert(projects) .values({ slug, name, purpose }) .returning(); const [member] = await tx .insert(members) .values({ projectId: project.id, tokenHash: hashToken(nanoid(TOKEN_LENGTH)), // web-session token; unused by SP, see note displayName, ...encryptedEmailFields(memberEmail), emailVerified: true, // the PAT already proves email ownership color: MEMBER_COLORS[0], avatarIndex: 0, isCreator: true, role: 'admin', tosVersion: TOS_VERSION, tosAcceptedAt: new Date(), }) .returning(); await ensureProjectDefaults(tx, { projectId: project.id, memberId: member.id }); - Return
201 { project: serializeProject(project), memberId: member.id }.
Why no email-verification code here. POST /api/projects gates creation on a
6-digit emailed code or a proofToken from an existing Space (see
resolveProofEmail / proofVerified). A valid PAT is a strictly stronger proof
of the same email ownership — it was minted (api-tokens.ts) only after that
email was verified inside a Space. So emailVerified: true and skipping the code
is consistent with the existing proofToken shortcut, not a new trust
assumption.
The new member's token. SP does not need the returned web-session
token: because the new member shares the PAT's emailLookup, is
emailVerified, and carries the current tosVersion, loadIntegrationScope
immediately includes the new Space for the same PAT. So the existing PAT can
read/claim/patch in the new Space with no re-auth. Returning memberId is enough;
omit the session token (or return it for parity — your call).
Rate limiting. POST /api/projects is IP-rate-limited + code-gated. This
endpoint has neither, so add a per-email cap to stop a leaked PAT from mass-
creating Spaces — e.g. checkRateLimit('create-space-token:' + apiTokenId, N, window) (reuse lib/rate-limit.ts). Suggest something conservative (e.g. 10 /
hour).
Status codes: 201 created · 422 validation · 429 rate-limited ·
401 bad token.
4b. Scheduled time — expose scheduledAt / isRecurring, accept scheduledAt
SP syncs a task's scheduled time (task.dueWithTime) to a Plainspace item's
existing remindAt column. This is not a new endpoint — it extends the
SPTask DTO (read) and the existing PATCH /tasks/:id (write). No new tables;
items.remindAt + items.repeat and the whole reminder/repeat machinery already
exist. The DTO uses SP-facing names that map to those columns:
DTO field (SPTask) |
DB column | Meaning |
|---|---|---|
scheduledAt: string | null |
items.remindAt |
ISO instant the task is scheduled for, or null |
isRecurring: boolean |
items.repeat != null |
whether it repeats (cadence stays server-side) |
Read — add scheduledAt + isRecurring to serializeSPTask
return {
// …existing fields…
scheduledAt: item.remindAt ? item.remindAt.toISOString() : null,
isRecurring: item.repeat != null,
};
So getMyTasks$/getById$/claimable-tasks all carry them. isRecurring is the
yes/no flag SP needs to surface recurrence; the rule itself never crosses the wire.
Write — accept scheduledAt on PATCH /tasks/:id
Today the integration PATCH only accepts { done: boolean }. Widen its body
schema to also accept scheduledAt (mapped to the remindAt column):
PATCH /api/integration/tasks/:taskId
Body: { done?: boolean, scheduledAt?: string | null } // ISO instant, or null to unschedule
- Apply the same
remindAt/repeat/anchorinvariants the in-app PATCH uses (applyRepeatUpdateinitems.ts): clearingscheduledAt(→remindAt = null) cascades torepeat:null; re-scheduling a repeating item re-anchors the rule. SP never sends a rule, so ascheduledAt-only PATCH on a repeating item hits exactly the "re-anchor existing rule" branch. - Still member-scoped: the PAT can only patch items in
scope.projectIds, and only ever its own caller's item — same guard as the done write-back. SP settingscheduledAtis self-scoped scheduling, not assignment. - Validation: reject a
scheduledAtthat isn't a valid ISO instant ornull(422).
Recurrence — server stays authoritative, SP just tracks scheduledAt
Deliberately no rule translation between Plainspace RepeatRule and SP's
TaskRepeatCfg (different execution models — Plainspace = one persistent row the
sweep advances; SP = a template that spawns instances — and SP's recurrence is
mid-refactor). Instead:
- Plainspace → SP: a repeating item imports as a single ordinary SP task with
dueWithTime = scheduledAt(the next occurrence);isRecurringflags it. When the sweep advancesremindAt, SP's poll re-pulls the newscheduledAtand reschedules the same task. SP needs zero knowledge of the rule. - SP → Plainspace: SP only ever PATCHes a concrete
scheduledAt(never a rule). An SP-recurring task pushes each occurrence as a one-off; Plainspace keeps owning any rule it created.
Behavior to expect (not a bug): completing an imported recurring task in SP write-backs
done, the sweep then advances + un-checks the item, and SP's next poll reopens it at the new time. Correct for a recurring item; the done-write-back ↔ scheduledAt-re-pull interaction wants an idempotency test.
Client scope in PR #8424
The SP side imports scheduledAt → dueWithTime on task add (schedule shows in the
app, with isRecurring available to flag recurrence) and pushes
dueWithTime → scheduledAt (incl. null on unschedule), mirroring the done
write-back. The recurrence-tracking poll extension (re-pull an advanced
scheduledAt) is a documented follow-up. dueDay (date-only SP scheduling) is
intentionally not synced — scheduledAt always carries a time, so mapping a
day-only task would fabricate one.
4c. New endpoint 4 — Create a task (add directly to Plainspace)
POST /api/integration/tasks
Authorization: Bearer pat_…
body: { spaceId: string, title: string } // title 1–500 chars
→ 201 { task: SPTask }
The symmetric twin of claimTask$: it lets a task added to a Plainspace-backed
SP project appear for the team. When SP adds a task to a project that has a bound
PLAINSPACE provider, it POSTs here and links the returned SPTask.id back to
the local task (then the existing done/title/scheduledAt write-back keeps it in
sync). spaceId accepts the project UUID or slug (same as everywhere SP
holds cfg.spaceId); resolve it against scope.projectIds so a token can't
create tasks in a foreign Space.
This is the PAT-authed integration-API equivalent of the existing member-token
route POST /api/projects/:slug/items — reuse that route's item-creation logic
(append at end of the project's primary/hero list, recordActivity, broadcast
item.created over SSE), just behind apiTokenMiddleware + loadIntegrationScope
and returning the SPTask DTO instead of the internal activity entry. The
member-token route is not reusable from SP directly: the SP client only ever
holds a PAT (pat_…), never a per-session member token.
const { spaceId, title } = c.req.valid('json'); // CreateTaskViaTokenSchema
const scope = await loadIntegrationScope(emailLookup);
const projectId = resolveScopedProjectId(spaceId, scope); // UUID or slug → UUID, or 404
// then the same insert path as POST /api/projects/:slug/items (primary list,
// position = max+GAP), serializeSPTask(row) → c.json({ task }, 201)
- Status codes:
201created ·422validation ·404unknown/foreignspaceId·401/428auth (same as the other integration routes). - No new table.
titlemaps to the itemtextcolumn (MAX_ITEM_TEXT_LENGTH). - SP lets create errors propagate (unlike its fail-soft reads) so a failed add surfaces a snack instead of silently dropping the task.
Client side is already implemented (this repo): PlainspaceApiService.createTask$,
PlainspaceSyncAdapterService.createIssue, and _hasAutoCreateEnabled recognising
the native PLAINSPACE key (no opt-in flag — the bound provider is the opt-in).
It wires into the generic autoCreateIssueOnTaskAdd$ effect and is inert until
this endpoint ships.
5. Onboarding caveat (important for the SP "Share" UX)
A PAT can only be minted from inside an existing Space
(POST /api/projects/:slug/auth/api-tokens requires a logged-in, email-verified
member). Therefore:
- Creating an additional Space from SP (the user already has a PAT) → fully covered by §4. ✅
- Creating a user's first Space from SP (no Space, no PAT yet) → not
possible with PAT-only, because there's nothing to mint a PAT from. This is the
chicken-and-egg the device-code flow in
docs/plans/2026-06-02-super-productivity-integration.md("Auth: device-code, not copy-paste") is meant to solve. Until that lands, SP should gate "Share on Plainspace" behind "paste a PAT" (i.e. the user is already a Plainspace member) and word the empty state accordingly.
Recommend scoping device-code as its own follow-up (the SP doc already proposes
the POST /api/integration/device-code + …/device-token pair). It is the
single biggest UX unlock but is independent of the three endpoints above.
6. Optional — efficient polling (?updatedSince=)
Already flagged in the SP-integration brainstorm under "Where this work lives". Not required for correctness; worth it once many tasks sync.
- Add an
updatedAt timestamptzcolumn toitems(defaultNow(), bumped on every mutating write — check/uncheck, assign, edit, restore). One migration + touching the existing item writes to set it. - Accept
GET /api/integration/tasks?updatedSince=<ISO>→and(…, gt(items.updatedAt, since)). - Add
updatedAtto theSPTaskDTO so SP can store a high-water mark.
SP would then poll with its last-seen timestamp instead of refetching the full assigned set each interval. Defer until the read volume justifies it.
7. Shared types to add (packages/shared/src/types.ts)
// response of POST /api/integration/tasks/:id/claim
export interface SPClaimTaskResponse {
task: SPTask;
}
// response of GET /api/integration/claimable-tasks (can reuse SPTasksResponse)
export type SPClaimableTasksResponse = SPTasksResponse;
// response of POST /api/integration/spaces
export interface SPCreateSpaceResponse {
project: Pick<Project, 'id' | 'slug' | 'name' | 'purpose' | 'sharingMode'>;
memberId: string;
}
And in packages/shared/src/validation.ts:
export const CreateSpaceViaTokenSchema = z.object({
name: z.string().min(1).max(MAX_PROJECT_NAME_LENGTH),
purpose: z.string().max(MAX_PURPOSE_LENGTH).default(''),
displayName: z.string().min(1).max(MAX_DISPLAY_NAME_LENGTH).optional(),
});
SPTask itself is unchanged (add updatedAt only if §6 is taken).
8. Tests (mirror routes/integration.test.ts)
integration.test.ts already has the harness (PAT minting + app.request).
Add, in the same style:
claim
- claims an unassigned task →
200, rowassignedTo === myMember,item.assignedactivity row written, SSEitem.updatedemitted. - claiming an already-assigned task →
409, row unchanged. - claiming a task in a project I'm not a member of →
404(isolation). - two concurrent claims → exactly one
200, one409(atomic-update race). - self-assignment does not insert an
assignmentNotificationsrow.
claimable-tasks
- returns only
assignedTo IS NULL AND checked = false AND deletedAt IS NULLwithin my projects; excludes mine/others'/done/deleted. ?projectId=outside my scope returns[](no foreign-project probe).
create-space
201;projects+ creatormembers+ defaultlists/scratchpadsrows exist; the same PAT can immediatelyGET /tasksscoped to the new project.- validation failure →
422; over-limit →429.
9. How SP consumes each (so the contract is mutually legible)
All isolated in SP's PlainspaceApiService (one file) — see
docs/plainspace-integration-plan.md:
| SP method | Endpoint | Notes |
|---|---|---|
getMe$ / testConnection |
GET /me |
identity + space list |
getMyTasks$ |
GET /tasks |
client-filters task.projectId === cfg.spaceId |
getById$ / poll |
GET /tasks/:id |
freshness for imported tasks |
| done write-back | PATCH /tasks/:id { done } |
on SP task complete/reopen |
| scheduled-time sync | PATCH /tasks/:id { scheduledAt } + read |
dueWithTime ↔ scheduledAt (§4b) |
getUnclaimedTasks$ |
GET /claimable-tasks?projectId=cfg.spaceId |
claim pool feed |
claimTask$ |
POST /tasks/:id/claim |
then addTaskFromIssue imports it |
createTask$ |
POST /tasks { spaceId, title } |
add task in a bound project → link SPTask.id |
createSpace$ |
POST /spaces |
bind provider spaceId = project.id |
Two client-side fixes SP must make when going real (server unaffected, noting
for completeness): send Authorization: Bearer <PAT> on every call (the PAT
lives in PlainspaceCfg, not a mock account), and use SPTask.url directly for
"open in Plainspace" instead of constructing …/spaces/:id/tasks/:id (the real
link is itemUrl = {origin}/{slug}/item/{id}).
10. Out of scope (separate plans)
- Device-code auth (
/api/integration/device-code+/device-token) — the real onboarding fix; see §5 and the SP brainstorm doc. - SP → Plainspace promotion (assign an SP task to someone → seed a new
Space + invite) — the dominant flow in the product vision, larger than this
PR's needs. Note: adding a task to an already-bound Space is now in scope —
see §4c (
POST /api/integration/tasks). - Assignee/“waiting-on” surfacing, presence, comments, attachments.
- Per-occurrence reminders / repeat rules over the integration channel.
11. Summary — minimum to unblock SP PR #8424
POST /api/integration/tasks/:taskId/claim(§2) — required for the claim pool.GET /api/integration/claimable-tasks(§3) — required for the claim pool.POST /api/integration/spaces(§4) — required for "Share on Plainspace" (additional Spaces; first-Space onboarding waits on device-code, §5).scheduledAt/isRecurringonserializeSPTask+scheduledAtonPATCH /tasks/:id(§4b) — required for scheduled-time sync. No new endpoint/table; extends the read DTO + PATCH via the in-appapplyRepeatUpdatepath (DTOscheduledAt↔ dbremindAt).?updatedSince=+items.updatedAt(§6) — optional, polling efficiency.
All three required endpoints are ~1 handler each, reuse apiTokenMiddleware /
loadIntegrationScope / serializeSPTask / recordActivity / sseManager, and
add no new tables. Estimated surface: one new file or ~150 lines appended to
routes/integration.ts, a handful of shared-type lines, and the tests in §8.