diff --git a/docs/plainspace-api-extension-plan.md b/docs/plainspace-api-extension-plan.md new file mode 100644 index 0000000000..3f02a03b09 --- /dev/null +++ b/docs/plainspace-api-extension-plan.md @@ -0,0 +1,462 @@ +# 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/integration` surface 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** + +1. Load scope. If empty → `404 { error: 'Task not found' }`. +2. Look up the item by id, restricted to `scope.projectIds`, `isNull(deletedAt)`. + Not found → `404`. +3. Resolve the caller's member for that project: + `member = scope.memberByProjectId.get(item.projectId)`. Missing → `404`. +4. **Atomic claim** (handles the race where two members claim at once — same + conditional-update pattern as the verification-code claim in `projects.ts`): + + ```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 + ``` + +5. `recordActivity(tx, { action: 'item.assigned', targetType: 'item', +targetId: taskId, memberId: member.id, meta: { text: item.text, +assignedTo: member.id, source: 'sp' } })`. +6. After commit: `sseManager.broadcast(projectId, 'item.updated', +{ item: serializeItem(claimed), memberId: member.id })` and the `activity` + broadcast — exactly like the PATCH handler, so open web clients see the claim + live. +7. **Do not** enqueue an `assignmentNotifications` row: claiming is a + self-assignment, and that table already excludes self-assignments by design + (see the comment on `assignmentNotifications` and the `assignee !== member.id` + guard in `items.ts`). Pinging yourself about a task you just claimed is noise. +8. Return `{ task: serializeSPTask(claimed, list, proj, origin) }` (fetch `list` + - `proj` as 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 arbitrary `assignedTo` over 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). `claim` is the one safe, self-scoped assignment: +> it can only ever set `assignedTo = me`, and only from unassigned. This matches +> the asymmetry in `docs/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= (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: + +```ts +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 with `scope.projectIds` + so it can't be used to probe foreign projects. SP passes its bound + `spaceId` here to avoid over-fetching unclaimed tasks from unrelated Spaces. +- Visibility is membership-based and independent of `sharingMode`: members of a + `private` Space 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` (default +> `scope=assigned`). The dedicated path keeps `/tasks` semantics 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** + +1. Decrypt the PAT's email: `decryptStoredEmail(row)` is already done in + `apiTokenMiddleware`; expose it via `c.get('apiTokenEmail')` (the middleware + sets it). Normalize with `normalizeEmail`. +2. Validate body with a new `CreateSpaceViaTokenSchema` (zod, in + `packages/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). `safeParse` failure → `422` with `details: error.flatten()`. +3. Transaction (mirrors `projects.ts`): + ```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 }); + ``` +4. 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` + +```ts +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`/`anchor` invariants** the in-app PATCH uses + (`applyRepeatUpdate` in `items.ts`): clearing `scheduledAt` (→ `remindAt = null`) + cascades to `repeat:null`; re-scheduling a repeating item re-anchors the rule. + SP never sends a rule, so a `scheduledAt`-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 + setting `scheduledAt` is self-scoped scheduling, not assignment. +- Validation: reject a `scheduledAt` that isn't a valid ISO instant or `null` + (`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); `isRecurring` flags it. When + the sweep advances `remindAt`, SP's poll re-pulls the new `scheduledAt` and + 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. + +--- + +## 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 timestamptz` column to `items` (`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=` → `and(…, gt(items.updatedAt, since))`. +- Add `updatedAt` to the `SPTask` DTO 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`) + +```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; + memberId: string; +} +``` + +And in `packages/shared/src/validation.ts`: + +```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`, row `assignedTo === myMember`, `item.assigned` + activity row written, SSE `item.updated` emitted. +- 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`, one `409` (atomic-update race). +- self-assignment does **not** insert an `assignmentNotifications` row. + +**claimable-tasks** + +- returns only `assignedTo IS NULL AND checked = false AND deletedAt IS NULL` + within my projects; excludes mine/others'/done/deleted. +- `?projectId=` outside my scope returns `[]` (no foreign-project probe). + +**create-space** + +- `201`; `projects` + creator `members` + default `lists`/`scratchpads` rows + exist; **the same PAT** can immediately `GET /tasks` scoped 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 | +| `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 ` 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 Space + + invite) — the dominant flow in the product vision, larger than this PR's needs. +- **Assignee/“waiting-on” surfacing**, presence, comments, attachments. +- Per-occurrence reminders / repeat rules over the integration channel. + +--- + +## 11. Summary — minimum to unblock SP PR #8424 + +1. `POST /api/integration/tasks/:taskId/claim` (§2) — **required** for the claim pool. +2. `GET /api/integration/claimable-tasks` (§3) — **required** for the claim pool. +3. `POST /api/integration/spaces` (§4) — **required** for "Share on Plainspace" + (additional Spaces; first-Space onboarding waits on device-code, §5). +4. `scheduledAt`/`isRecurring` on `serializeSPTask` + `scheduledAt` on + `PATCH /tasks/:id` (§4b) — **required** for scheduled-time sync. No new + endpoint/table; extends the read DTO + PATCH via the in-app `applyRepeatUpdate` + path (DTO `scheduledAt` ↔ db `remindAt`). +5. `?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. diff --git a/docs/plainspace-integration-plan.md b/docs/plainspace-integration-plan.md new file mode 100644 index 0000000000..17425c90bc --- /dev/null +++ b/docs/plainspace-integration-plan.md @@ -0,0 +1,458 @@ +# Plainspace Integration Plan + +Integrating **Plainspace** (plainspace.org — repo `Johannesjo/spaces`) into Super +Productivity (SP) so that: + +1. A project can be made **shared on Plainspace** directly from the project + create/edit dialog. +2. For such shared projects, SP shows — by **task ownership** (see the model in + §1): + - **My list** — tasks assigned to me, as regular, editable SP tasks. + - **A read‑only claim pool** — unclaimed (unassigned) tasks you can **claim**; + claiming assigns the task to you in Plainspace and imports it as an SP task. + - Tasks **assigned to others are not represented in SP** at all. + +> **Conceptual note (revised):** an earlier draft mirrored "assigned to others" +> into SP as a standing read-only list. We dropped that — SP is a personal focus +> tool, and a permanent wall of others' non-actionable tasks works against it and +> creates a stale second copy of the Plainspace board. The model is now: **only +> _mine_ + _unclaimed_ appear in SP; claiming is the bridge** that turns shared +> work into your work. See the conversation rationale captured in §7. + +> Status: planning + prototype. The Plainspace HTTP API contract is not yet +> pinned down in this document (see [Open questions](#10-open-questions--blocking-decisions)); +> the prototype is built against an **assumed contract** isolated behind a single +> API service so it can be corrected in one place once the real API is known. +> +> **Implemented today (mock-backed):** +> +> - §4 — the `PLAINSPACE` issue provider (`providers/plainspace/`): config form, +> `PlainspaceApiService` (mock mode via `PLAINSPACE_USE_MOCK`), +> `PlainspaceCommonInterfacesService` implementing `IssueServiceInterface`, +> registered in `issue.model.ts` / `issue.const.ts` / `issue.service.ts` + +> icon. **Only tasks assigned to me** import via the issue→backlog pipeline. +> - §5 — account / identity: `PlainspaceAccountService` (signals: `account`, +> `isLoggedIn`, `currentUserId`; localStorage-persisted, never synced) with a +> mock `login`/`logout`. "Mine" comes from the signed-in identity, and the +> share toggle prompts sign-in if needed. +> - §6 — the "Share on Plainspace" toggle in the create-project dialog, which +> (after sign-in) provisions a (mock) space and a bound provider via +> `PlainspaceShareService`. +> - §7 — the read-only **claim pool**: `PlainspaceClaimPoolService` feeds +> unclaimed tasks (mock) through `project-task-page` → `work-view` into a +> collapsed-by-default panel (`PlainspaceClaimPoolComponent`). A **Claim** +> action assigns the task to me and imports it as an SP task. Shows only for +> shared projects. +> +> **Still design-only:** §8 write-back, and the real HTTP API + real auth (all +> `PlainspaceApiService` calls, the login, and claim are mocked — see §10). The +> claim pool does not yet auto-poll (loads on project open / provider change / +> after a claim). + +--- + +## 1. Guiding decisions (agreed) + +| Decision | Choice | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| **Source of truth** for shared data | **Plainspace backend.** SP reads/writes shared tasks via Plainspace's API as a separate channel from SP's own op‑log sync. | +| **Integration shape** | Model Plainspace as a **regular issue provider** (like Jira/Redmine) "for the most part". | +| **Identity** | **Plainspace account login** (token-based). The authenticated account defines "me" — which tasks are mine vs unclaimed. | +| **v1 scope** | Plan the full feature; build a working **prototype** (UI + provider scaffold against an assumed/mock API). | + +### Why "issue provider" is the right host + +SP already has a mature, well-factored issue-provider system (Jira, GitLab, +CalDAV, OpenProject, Trello, Redmine, Azure DevOps, Nextcloud Deck). It gives us, +for free: + +- Per-provider config + Formly config form, stored in the issue-provider NgRx + store and bindable to a specific project via `defaultProjectId`. +- Search in the add-task bar, "add issue as task", attachments mapping. +- **Auto-import to backlog** (`getNewIssuesToAddToBacklog`) and **polling for + fresh data** (`getFreshDataForIssueTask`) with a configurable `pollInterval`. +- A clean single interface to implement: `IssueServiceInterface`. + +This means "Plainspace issues assigned to me" flow through the **existing** +issue→task pipeline with almost no new core code. The only genuinely new surface +is the read-only **claim pool**, because that data is _not_ imported as SP tasks +until claimed. + +### The one important nuance + +The standard issue-provider flow turns issues **into** SP tasks. We only do that +for tasks **assigned to me**. Unclaimed tasks are _shown_ (a read-only pool) but +**not** auto-imported — claiming is a deliberate act that assigns the task to me +and then imports it. Tasks assigned to others are not represented in SP at all. +(Auto-importing _unclaimed_ work as if it were yours has the same problem as the +old others-list, just subtler: two members connecting the same space would both +"own" the same unclaimed task locally.) See §7 for the full ownership model. + +--- + +## 2. Architecture overview + +``` + ┌─────────────────────────────────────────────┐ + │ Plainspace API │ + │ spaces (projects) · tasks · members · auth │ + └───────────────┬───────────────┬─────────────┘ + │ │ + (issue-provider channel) (shared-project channel) + │ │ + ┌────────────────────────────────▼──┐ ┌────────▼───────────────────────────┐ + │ PlainspaceApiService (HTTP) │ │ PlainspaceAccountService (auth/me) │ + │ PlainspaceCommonInterfacesService │ │ PlainspaceClaimPoolService │ + │ implements IssueServiceInterface │ │ (unclaimed tasks + claim action) │ + └───────────────┬────────────────────┘ └───────────────┬────────────────────┘ + │ │ + ┌───────────────▼────────────────┐ ┌────────────────▼───────────────────┐ + │ Existing issue→task pipeline │ │ Read-only claim-pool panel │ + │ → real SP tasks (assigned to me)│ claim │ in work-view (unclaimed tasks) │ + └─────────────────────────────────┘◄───────┴─────────────────────────────────────┘ + │ │ + └──────────────► Project work view ◄───────┘ + (My list) (Claim pool) +``` + +- **Issue-provider channel** = the Jira-like path. Registers a `PLAINSPACE` + provider, bound per project via `defaultProjectId` (= the SP project the space + maps to). Imports **assigned-to-me** issues, polls them for freshness. +- **Shared-project channel** = the new bits: account login, the unclaimed claim + pool (+ claim → import), and creating a space when a project is shared. + +SP's own op-log/vector-clock sync is **untouched**: shared data does not flow +through it. (Doing so would mean teaching the single-user op-log to carry +multi-user ops — explicitly rejected as too risky.) + +--- + +## 3. Data model changes + +### 3.1 New issue-provider config (`PlainspaceCfg`) + +New folder `src/app/features/issue/providers/plainspace/`. Config interface (mirrors +`RedmineCfg`): + +```ts +export interface PlainspaceCfg extends BaseIssueProviderCfg { + host: string | null; // plainspace.org or self-hosted base URL + spaceId: string | null; // the Plainspace "space" this provider is bound to + token?: string | null; // PAT (pat_…) authorizing this provider's API calls +} +``` + +> **Where the token lives (as built).** The PAT is stored on `PlainspaceCfg.token` +> and authorizes every `PlainspaceApiService` call — exactly like Jira's +> `password` or CalDAV's `password`, and like them it is part of synced +> issue-provider state and is included in plaintext backups/exports. This is a +> deliberate parity choice (a provider works on a fresh device after sync without +> re-pasting), and is the accepted secret-handling posture for issue providers. +> The account store (§3.3, local-only `localStorage`) holds a token **too**, but +> only to bootstrap the "Share on Plainspace" flow, which needs a token *before* +> any provider exists; the provider runtime reads only `cfg.token`. An earlier +> draft said the token was not stored in the cfg — that was never the case in the +> shipped code. + +### 3.2 Plainspace issue/task shapes (assumed — single source to fix later) + +```ts +// src/app/features/issue/providers/plainspace/plainspace-issue.model.ts +export interface PlainspaceMember { + id: string; + name: string; + avatarUrl?: string; +} + +export interface PlainspaceIssue { + id: string; + title: string; + isDone: boolean; + assigneeId: string | null; // null = unassigned + assignee?: PlainspaceMember | null; + updatedAt: string; // ISO + url?: string; + // ...extend once the real API is known +} +``` + +### 3.3 Account / identity (new, small store) + +```ts +// src/app/features/plainspace/plainspace-account.model.ts +export interface PlainspaceAccount { + host: string; // which plainspace instance + userId: string; // "me" + displayName: string; + token: string; // bearer token (stored like other provider creds) +} +``` + +Stored per SP profile alongside other credentials (same mechanism existing +providers use for secrets). One account → many spaces. + +### 3.4 No change to the SP `Task` model in v1 + +- "Mine/unassigned" tasks are normal SP tasks; their Plainspace origin is already + captured by the existing `issueId` / `issueProviderId` / `issueType` fields. +- "Assigned to others" tasks are **not** SP tasks, so they need no `Task` field. + An `assignee` field on SP tasks is **explicitly deferred** (would touch the + hot-path task component and sync) — see [Future work](#11-future-work). + +### 3.5 Project ↔ space link + +The link is expressed entirely through the issue-provider instance: +`IssueProviderPlainspace.defaultProjectId` = SP project id, and +`PlainspaceCfg.spaceId` = remote space id. No new field on `Project` is strictly +required. (Optional convenience flag `Project.isSharedOnPlainspace` could be added +later for menu/badge rendering, but is not needed for correctness.) + +--- + +## 4. Phase 1 — Plainspace issue provider scaffold + +Goal: `PLAINSPACE` exists as a first-class issue provider; my/unassigned issues +import as tasks and poll. Pattern reference: **Redmine** (simplest built-in). + +### 4.1 Central registration (4 edits) + +- `src/app/features/issue/issue.model.ts` + - add `'PLAINSPACE'` to `BuiltInIssueProviderKey` + `BUILT_IN_KEYS` + - add `PlainspaceCfg` to `IssueIntegrationCfg` union and + `IssueIntegrationCfgs` map + - add issue type to `IssueData` / `IssueDataReduced` (+ `IssueDataReducedMap`) + - add `IssueProviderPlainspace extends IssueProviderBase, PlainspaceCfg` + (`issueProviderKey: 'PLAINSPACE'`) and add it to the `IssueProvider` union and + `IssueProviderTypeMap`. +- `src/app/features/issue/issue.const.ts` + - `PLAINSPACE_TYPE`, add to `ISSUE_PROVIDER_TYPES`, + `ISSUE_PROVIDER_ICON_MAP`, `ISSUE_PROVIDER_HUMANIZED`, + `DEFAULT_ISSUE_PROVIDER_CFGS`, `ISSUE_PROVIDER_FORM_CFGS_MAP`, `ISSUE_STR_MAP`. +- `src/app/features/issue/issue.service.ts` + - import + inject `PlainspaceCommonInterfacesService`, add to + `ISSUE_SERVICE_MAP`. +- Provider icon: add `src/assets/icons/plainspace.svg` **and** register it in + `GlobalThemeService` (`_initIcons()`, the `addSvgIcon(...)` block) — the + `ISSUE_PROVIDER_ICON_MAP` value only names the icon, it does not register it. + Note `ISSUE_PROVIDER_HUMANIZED` is a plain string ('Plainspace'), not a `T` + key, so no translation entry is needed for the provider name itself. + +> Not strictly 4 files: adding `'PLAINSPACE'` to `BuiltInIssueProviderKey` also +> widens `IssueProviderKey`, so the existing `Task.issueType` field gains +> `'PLAINSPACE'` as a valid value. No new `Task` field, but it is a (safe, +> additive) type-surface change to be aware of. + +### 4.2 New provider files (`providers/plainspace/`) + +| File | Responsibility | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `plainspace.model.ts` | `PlainspaceCfg` | +| `plainspace-issue.model.ts` | `PlainspaceIssue`, `PlainspaceMember` | +| `plainspace.const.ts` | `DEFAULT_PLAINSPACE_CFG`, `PLAINSPACE_POLL_INTERVAL` | +| `plainspace-cfg-form.const.ts` | Formly config form + `..._CONFIG_FORM_SECTION` (host, advanced common fields) | +| `plainspace-api.service.ts` | All HTTP: `searchIssues$`, `getById$`, `getTasksForSpace$`, `getMembers$`, `createSpace$`, plus mock mode | +| `plainspace-common-interfaces.service.ts` | implements `IssueServiceInterface` (extends `BaseIssueProviderService`) | +| `plainspace-issue-map.util.ts` | `PlainspaceIssue → SearchResultItem` and `→ getAddTaskData` | + +### 4.3 `IssueServiceInterface` implementation notes + +- `isEnabled(cfg)` → `cfg.isEnabled && !!cfg.host && !!cfg.spaceId` and an account + token present. +- `getAddTaskData(issue)` → `{ title, isDone, issuePoints? }`. **Filter at the + source**: only my/unassigned issues are ever offered to this path (see 4.4). +- `getNewIssuesToAddToBacklog(providerId, existingIds)` → fetch space tasks + where `assigneeId === me || assigneeId == null`, minus `existingIds`. +- `getFreshDataForIssueTask(task)` → re-fetch by id, return `isDone`/title + changes (never overwrite user scheduling). +- `pollInterval` → `PLAINSPACE_POLL_INTERVAL` (e.g. 5 min). Reuses existing + `poll-issue-updates.effects.ts` and `poll-to-backlog.effects.ts`. + +### 4.4 The mine/unassigned filter + +Centralize in `plainspace-api.service` (`getMyAndUnassignedTasks$`) so both the +backlog import and the search path only ever see items that are valid to import. +"Assigned to others" is fetched by a sibling method and never reaches the issue +pipeline. + +--- + +## 5. Phase 2 — Account login / identity + +Goal: establish "me" so the assigned/unassigned split is meaningful. + +- `src/app/features/plainspace/plainspace-account.service.ts` — login (token + exchange), store/clear account, expose `me$` (signal) and `currentUserId`. +- Login UI: a button in the Plainspace provider config form (`testConnection` + doubles as "verify login"), and/or a small dialog. Token persisted via the same + secret-storage path other providers use. +- `isEnabled` and the shared-tasks fetch both depend on a valid account; surface a + clear "not logged in" state. + +Auth mechanism (token vs OAuth redirect) depends on what Plainspace exposes — see +[Open questions](#10-open-questions--blocking-decisions). If OAuth is required, +reuse the existing `src/app/plugins/oauth/` helpers. + +--- + +## 6. Phase 3 — "Share on Plainspace" in the project create/edit dialog + +Goal: a toggle in `dialog-create-project` that provisions a Plainspace space and +wires up the provider binding. + +- **Form**: add an `isShareOnPlainspace` checkbox to + `CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG` + (`src/app/features/project/project-form-cfg.const.ts`). Gate it behind "account + logged in" (show a login affordance if not). +- **On submit** (in `dialog-create-project.component.ts`), after the project is + created via `projectService.add()`: + 1. If `isShareOnPlainspace` and logged in → `PlainspaceApiService.createSpace$( +{ title })` → returns `spaceId`. + 2. Create a `PLAINSPACE` issue-provider instance with + `{ host, spaceId, isEnabled: true, defaultProjectId: , +isAutoAddToBacklog: true }` via the issue-provider store. +- **Edit mode**: same toggle reflects whether a bound Plainspace provider exists; + turning it on later provisions the space + provider; turning it off should + prompt (unlink vs delete remote) — keep v1 to **unlink only** (disable provider, + leave remote space intact) to avoid destructive surprises. +- **i18n**: new strings via `T`/`en.json` only. + +--- + +## 7. Ownership model & the claim pool (revised) + +**The conceptual question that reshaped this feature:** _which tasks from a +shared space should appear in a personal focus app at all?_ Answer — by +ownership state: + +| Plainspace state | In SP? | Treatment | +| -------------------- | ------------------- | ------------------------------------------------------------- | +| Assigned to me | Yes — the point | First-class SP tasks: schedule, time-track, complete. | +| Unclaimed | As a pool, not list | Read-only "claim pool"; **Claim** → assign-to-self → SP task. | +| Assigned to others | No | Not represented in SP (Plainspace board is the team view). | +| Done (others/unass.) | No | Irrelevant to the individual. | + +**Claiming is the bridge** between the collective space and the personal app: +the only way unclaimed work becomes yours. This avoids mirroring a stale, +non-actionable copy of other people's tasks into a focus tool. (We also skip +per-task assignee badges: with only _mine_ + _unclaimed_ shown, ownership is +implicit, and SP already shows a provider icon on issue-linked tasks.) + +### 7.1 Read-only claim-pool component + +`src/app/features/plainspace/claim-pool/claim-pool.component.ts` + +- Input: `tasks: PlainspaceSharedTask[]` (unclaimed) + `projectId`. +- Flat read-only rows: title, "open in Plainspace" link, **Claim** button. No + drag/drop, scheduling, time tracking, or task-store interaction. + +> Deliberately a **lightweight standalone component**, not the hot-path +> `task.component` — these are foreign tasks until claimed. + +### 7.2 Data flow + +- `PlainspaceClaimPoolService.unclaimedTasksForProject$(projectId)` finds the + bound enabled `PLAINSPACE` provider and returns its unclaimed tasks + (`assigneeId === null && !isDone`), refreshing after a claim. +- `claim(projectId, taskId)` → `PlainspaceApiService.claimTask$` (assign-to-me) + → `IssueService.addTaskFromIssue(... isAddToBacklog)` → pool refreshes. +- `project-task-page` derives `unclaimedTasks` and passes it (+ `projectId`) + into `work-view`. + +### 7.3 Layout + +A `collapsible` section (mirrors overdue/done panels), **collapsed by default** +(it's a pool you reach for, not your active work); state persisted in +`localStorage` (`LS.PLAINSPACE_CLAIM_POOL_HIDDEN`). + +--- + +## 8. Phase 5 — Polling, refresh & write-back + +- **Reads**: reuse issue polling for my tasks; add a light timer in + `PlainspaceClaimPoolService` to refresh the claim pool (same interval), only + while the shared project is open. Today it refreshes on project open / provider + change / after a claim — no timer yet. +- **Writes (mine)**: completing/editing a _my_ imported task should optionally + push back to Plainspace via `updateIssueFromTask` (the optional interface hook). + Start **read-mostly**: import + status sync for done-state only; expand later. +- **Claim**: the one write already implemented — assign an unclaimed task to me + (`claimTask$`), then import it. Real mode would POST the assignment. +- **Offline**: all Plainspace calls must fail soft (empty lists, cached last + values) and never block the SP UI; SP remains fully usable offline. + +--- + +## 9. Prototype scope (this iteration) + +A runnable prototype that demonstrates the UX end-to-end against a **mock** +Plainspace backend (toggled by a flag in `PlainspaceApiService`), so it works with +no live server and is trivially swapped for the real API: + +1. `PLAINSPACE` provider registered + config form (Phase 1 skeleton). +2. `PlainspaceApiService` with a **mock mode** returning canned spaces, members, + and tasks (mix of mine / unassigned / others). +3. "Share on Plainspace" toggle in the create dialog that, in mock mode, fakes + space creation and provisions the provider binding (Phase 3). +4. The **"Assigned to others"** read-only panel wired into the work view for + shared projects (Phase 4) — the visually novel part. +5. My/unassigned issues importing into the normal list via the issue pipeline. + +Out of prototype scope: real auth handshake, write-back, attachments, subtasks, +production error/empty states polish. + +--- + +## 10. Open questions / blocking decisions + +These need answers (ideally the Plainspace API docs / the `Johannesjo/spaces` +repo, which I could not access from this environment) to move the prototype onto +the real backend: + +1. **Auth**: token/API-key, or OAuth redirect flow? Endpoint(s)? How is "me" + (current user id) returned? +2. **Spaces API**: create space (`POST`?), list spaces, get members of a space. +3. **Tasks API**: list/get tasks for a space; fields available (esp. `assigneeId`, + done state, ordering); search; pagination. +4. **Write-back**: can SP create/update tasks and assignments? Required for the + "share" flow to push SP tasks up, vs. pull-only. +5. **Hosting**: is it always plainspace.org, or self-hostable (host field needed)? +6. **CORS/Electron**: does the API allow browser-origin calls, or must requests go + through the Electron main process (like some providers do)? + +## 11. Future work + +- Optional `Project.isSharedOnPlainspace` flag for menu badges. +- A real `assignee` concept on SP tasks (hot-path + sync implications — separate + design). +- Reassign-from-SP, presence/avatars, comments, two-way task creation. + +## 12. Risks + +- **Cross-client forward-compat (rollout-gating).** Adding `'PLAINSPACE'` to the + built-in `IssueProviderKey` union widens a **synced, typia-validated** type + (`issueProvider` state, and `task.issueType`). A client built before this change + has an AOT-baked validator that does not know the `'PLAINSPACE'` literal, so + when a newer client creates a Plainspace provider and syncs it, the older client + will reject the incoming model as corrupt (the documented "typia rejects unknown + union members" failure → false data-corruption dialog / rejected sync). This is + inherent to adding any new built-in provider key, and the mitigation is + **release sequencing**: the validator-aware build must reach the fleet **before** + any client can emit a `PLAINSPACE` provider. Do not back-port the ability to + create a Plainspace provider to a client that can't validate the key. (The + alternative — modelling Plainspace as a `plugin:`-style opaque key, which the + validator already accepts — is forward-compatible but is a larger change and is + the wrong shape for a built-in provider; revisit only if simultaneous rollout + can't be guaranteed.) +- **Sync correctness**: keep shared data **out** of the op-log; never route + Plainspace fetches through NgRx persisted actions. Imported "my" tasks follow + the existing, already-correct issue-task path. +- **Hot path**: the claim pool is a new lightweight component, not + `task.component`; verify against large lists. +- **API assumptions**: all isolated in `PlainspaceApiService` + + `plainspace-issue.model.ts` so the real contract changes one layer. +- **Privacy**: the PAT is stored in synced provider cfg like other provider + secrets (see §3.1); no analytics; log only ids (`Log.log({ id })`). + +``` + +``` diff --git a/src/app/core/persistence/storage-keys.const.ts b/src/app/core/persistence/storage-keys.const.ts index 0ec1dc360a..019e402109 100644 --- a/src/app/core/persistence/storage-keys.const.ts +++ b/src/app/core/persistence/storage-keys.const.ts @@ -71,6 +71,9 @@ export enum LS { LATER_TODAY_TASKS_HIDDEN = 'LATER_TODAY_TASKS_HIDDEN', OVERDUE_TASKS_HIDDEN = 'OVERDUE_TASKS_HIDDEN', REPEAT_CFGS_HIDDEN = 'REPEAT_CFGS_HIDDEN', + PLAINSPACE_CLAIM_POOL_HIDDEN = 'PLAINSPACE_CLAIM_POOL_HIDDEN', + // Plainspace account/identity — local-only, never synced (device identity). + PLAINSPACE_ACCOUNT = 'SUP_PLAINSPACE_ACCOUNT', // Magic side nav NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED', diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts index 055a1f065e..4e18df762d 100644 --- a/src/app/core/theme/global-theme.service.ts +++ b/src/app/core/theme/global-theme.service.ts @@ -205,6 +205,7 @@ export class GlobalThemeService { ['trello', 'assets/icons/trello.svg'], ['azure_devops', 'assets/icons/azure_devops.svg'], ['nextcloud_deck', 'assets/icons/nextcloud_deck.svg'], + ['plainspace', 'assets/icons/plainspace.svg'], ]; // todo test if can be removed with airplane mode and wifi without internet diff --git a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html index 60ef0b8561..49e072ab26 100644 --- a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html +++ b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html @@ -117,6 +117,14 @@ Nextcloud Deck + @for (provider of pluginProviders; track provider.pluginId) { + + } +} @else { +
{{ T.PLAINSPACE.NO_UNCLAIMED | translate }}
+} diff --git a/src/app/features/plainspace/claim-pool/claim-pool.component.scss b/src/app/features/plainspace/claim-pool/claim-pool.component.scss new file mode 100644 index 0000000000..f7ff257829 --- /dev/null +++ b/src/app/features/plainspace/claim-pool/claim-pool.component.scss @@ -0,0 +1,44 @@ +:host { + display: block; + padding: 0 var(--s); +} + +.claim-task { + display: flex; + align-items: center; + gap: var(--s); + padding: var(--s-half) var(--s); + border-bottom: 1px solid var(--extra-border-color); + color: var(--text-color); +} + +.title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.open-link { + flex-shrink: 0; + opacity: 0.6; + + &:hover { + opacity: 1; + } +} + +.recurring-icon { + flex-shrink: 0; + font-size: 18px; + width: 18px; + height: 18px; + opacity: 0.6; + color: var(--text-color-muted); +} + +.no-tasks { + padding: var(--s); + color: var(--text-color-muted); + font-style: italic; +} diff --git a/src/app/features/plainspace/claim-pool/claim-pool.component.ts b/src/app/features/plainspace/claim-pool/claim-pool.component.ts new file mode 100644 index 0000000000..6c0ba05b06 --- /dev/null +++ b/src/app/features/plainspace/claim-pool/claim-pool.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { T } from '../../../t.const'; +import { PlainspaceSharedTask } from '../plainspace-shared-task.model'; +import { PlainspaceClaimPoolService } from '../plainspace-claim-pool.service'; + +/** + * Read-only list of unclaimed Plainspace tasks for a shared project, each with a + * "Claim" action. Claiming assigns the task to me in Plainspace and imports it + * as a first-class SP task; the claimed task then leaves the pool. + * + * Deliberately a lightweight, standalone component (not the hot-path + * `TaskComponent`): these are foreign tasks until claimed and must never be + * edited/scheduled or written into the SP task store. See + * docs/plainspace-integration-plan.md. + */ +@Component({ + selector: 'plainspace-claim-pool', + templateUrl: './claim-pool.component.html', + styleUrls: ['./claim-pool.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, MatButton, MatIconButton, MatTooltip, TranslatePipe], +}) +export class PlainspaceClaimPoolComponent { + readonly T = T; + readonly tasks = input([]); + readonly projectId = input(''); + + private _claimPoolService = inject(PlainspaceClaimPoolService); + readonly claimingIds = signal>(new Set()); + + async claim(task: PlainspaceSharedTask): Promise { + const projectId = this.projectId(); + if (!projectId || this.claimingIds().has(task.id)) { + return; + } + this._setClaiming(task.id, true); + try { + await this._claimPoolService.claim(projectId, task.id); + } finally { + this._setClaiming(task.id, false); + } + } + + private _setClaiming(id: string, isClaiming: boolean): void { + this.claimingIds.update((set) => { + const next = new Set(set); + if (isClaiming) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + } +} diff --git a/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.html b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.html new file mode 100644 index 0000000000..748fb0e580 --- /dev/null +++ b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.html @@ -0,0 +1,63 @@ +

{{ T.PLAINSPACE.CONNECT.TITLE | translate }}

+ + +

{{ T.PLAINSPACE.CONNECT.INTRO | translate }}

+ +
    +
  1. {{ T.PLAINSPACE.CONNECT.STEP_1 | translate }}
  2. +
  3. {{ T.PLAINSPACE.CONNECT.STEP_2 | translate }}
  4. +
  5. {{ T.PLAINSPACE.CONNECT.STEP_3 | translate }}
  6. +
  7. {{ T.PLAINSPACE.CONNECT.STEP_4 | translate }}
  8. +
+ + + + + open_in_new + {{ T.PLAINSPACE.CONNECT.OPEN_LINK | translate }} + + + + {{ T.PLAINSPACE.CONNECT.TOKEN_LABEL | translate }} + + + + @if (hasError()) { +

{{ T.PLAINSPACE.CONNECT.INVALID | translate }}

+ } +
+ + + + + diff --git a/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.scss b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.scss new file mode 100644 index 0000000000..8af8e0d4f1 --- /dev/null +++ b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.scss @@ -0,0 +1,36 @@ +:host { + display: block; + max-width: 440px; +} + +.intro { + margin-top: 0; +} + +.steps { + margin: 0 0 12px; + padding-inline-start: 20px; +} + +.steps li { + margin-bottom: 4px; +} + +.email-hint { + margin: 0 0 16px; + font-size: 12px; + color: var(--text-color-muted); +} + +.open-link { + margin-bottom: 16px; +} + +.token-field { + width: 100%; +} + +.error { + margin: 4px 0 0; + color: var(--c-warn); +} diff --git a/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.spec.ts b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.spec.ts new file mode 100644 index 0000000000..9717c4dddf --- /dev/null +++ b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { PlainspaceConnectDialogComponent } from './plainspace-connect-dialog.component'; +import { PlainspaceAccountService } from '../plainspace-account.service'; + +describe('PlainspaceConnectDialogComponent', () => { + let component: PlainspaceConnectDialogComponent; + let fixture: ComponentFixture; + let dialogRef: jasmine.SpyObj>; + let accountService: jasmine.SpyObj; + + beforeEach(async () => { + dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + accountService = jasmine.createSpyObj('PlainspaceAccountService', ['connect']); + + await TestBed.configureTestingModule({ + imports: [ + PlainspaceConnectDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { host: 'https://plainspace.org' } }, + { provide: PlainspaceAccountService, useValue: accountService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PlainspaceConnectDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('renders and uses the provided host', () => { + expect(component).toBeTruthy(); + expect(component.host).toBe('https://plainspace.org'); + }); + + it('does nothing when the token is blank', async () => { + component.token = ' '; + await component.connect(); + expect(accountService.connect).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('connects with the trimmed token and closes with true on success', async () => { + accountService.connect.and.resolveTo(true); + component.token = ' pat_abc '; + await component.connect(); + expect(accountService.connect).toHaveBeenCalledWith( + 'pat_abc', + 'https://plainspace.org', + ); + expect(dialogRef.close).toHaveBeenCalledWith(true); + expect(component.hasError()).toBe(false); + }); + + it('shows an error and stays open on an invalid token', async () => { + accountService.connect.and.resolveTo(false); + component.token = 'bad'; + await component.connect(); + expect(component.hasError()).toBe(true); + expect(component.isConnecting()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('cancel closes with false', () => { + component.cancel(); + expect(dialogRef.close).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.ts b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.ts new file mode 100644 index 0000000000..1d02d7761b --- /dev/null +++ b/src/app/features/plainspace/connect-dialog/plainspace-connect-dialog.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogContent, + MatDialogRef, + MatDialogTitle, +} from '@angular/material/dialog'; +import { FormsModule } from '@angular/forms'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatAnchor, MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { T } from '../../../t.const'; +import { PlainspaceAccountService } from '../plainspace-account.service'; + +export interface PlainspaceConnectDialogData { + host?: string | null; +} + +/** + * Guided "Connect to Plainspace" dialog: shows where to create a personal API + * token (link + step-by-step), takes the pasted token and validates it against + * the host before closing. Resolves to `true` once connected, `false` if the + * user cancels. Replaces the bare single-line token prompt. + */ +@Component({ + selector: 'plainspace-connect-dialog', + templateUrl: './plainspace-connect-dialog.component.html', + styleUrls: ['./plainspace-connect-dialog.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatDialogTitle, + MatDialogContent, + MatDialogActions, + FormsModule, + MatFormField, + MatLabel, + MatInput, + MatButton, + MatAnchor, + MatIcon, + TranslatePipe, + ], +}) +export class PlainspaceConnectDialogComponent { + private _dialogRef = + inject>(MatDialogRef); + private _accountService = inject(PlainspaceAccountService); + private _data = inject(MAT_DIALOG_DATA, { + optional: true, + }); + + readonly T = T; + readonly host = this._data?.host || 'https://plainspace.org'; + token = ''; + readonly isConnecting = signal(false); + readonly hasError = signal(false); + + async connect(): Promise { + const token = this.token.trim(); + if (!token || this.isConnecting()) { + return; + } + this.isConnecting.set(true); + this.hasError.set(false); + const ok = await this._accountService.connect(token, this.host); + if (ok) { + this._dialogRef.close(true); + } else { + this.hasError.set(true); + this.isConnecting.set(false); + } + } + + cancel(): void { + this._dialogRef.close(false); + } +} diff --git a/src/app/features/plainspace/plainspace-account.model.ts b/src/app/features/plainspace/plainspace-account.model.ts new file mode 100644 index 0000000000..2e158a6903 --- /dev/null +++ b/src/app/features/plainspace/plainspace-account.model.ts @@ -0,0 +1,12 @@ +/** + * The connected Plainspace account: the personal API token (PAT) plus the host + * it was validated against, and the email it belongs to. Stored local-only (per + * device), never synced. Used by the share-on-create flow to provision a space + * before any provider exists, and to avoid re-entering the token. + */ +export interface PlainspaceAccount { + host: string; + /** Personal API token (`pat_…`). */ + token: string; + email: string; +} diff --git a/src/app/features/plainspace/plainspace-account.service.spec.ts b/src/app/features/plainspace/plainspace-account.service.spec.ts new file mode 100644 index 0000000000..434112550c --- /dev/null +++ b/src/app/features/plainspace/plainspace-account.service.spec.ts @@ -0,0 +1,68 @@ +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { PlainspaceAccountService } from './plainspace-account.service'; +import { LS } from '../../core/persistence/storage-keys.const'; + +describe('PlainspaceAccountService', () => { + let service: PlainspaceAccountService; + let httpMock: HttpTestingController; + const ME_URL = 'https://plainspace.org/api/integration/me'; + + beforeEach(() => { + localStorage.removeItem(LS.PLAINSPACE_ACCOUNT); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [PlainspaceAccountService], + }); + service = TestBed.inject(PlainspaceAccountService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + localStorage.removeItem(LS.PLAINSPACE_ACCOUNT); + httpMock.verify(); + }); + + it('starts logged out', () => { + expect(service.isLoggedIn()).toBe(false); + expect(service.token()).toBeNull(); + }); + + it('connect validates the token via /me, stores it, and persists', async () => { + const p = service.connect('pat_x'); + const req = httpMock.expectOne(ME_URL); + expect(req.request.headers.get('Authorization')).toBe('Bearer pat_x'); + req.flush({ email: 'me@example.com', projects: [] }); + + expect(await p).toBe(true); + expect(service.isLoggedIn()).toBe(true); + expect(service.token()).toBe('pat_x'); + expect(service.account()?.email).toBe('me@example.com'); + expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toContain('pat_x'); + }); + + it('connect returns false and stays logged out on an invalid token', async () => { + const p = service.connect('bad'); + httpMock + .expectOne(ME_URL) + .flush({ error: 'nope' }, { status: 401, statusText: 'Unauthorized' }); + + expect(await p).toBe(false); + expect(service.isLoggedIn()).toBe(false); + expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull(); + }); + + it('logout clears the account and storage', async () => { + const p = service.connect('pat_x'); + httpMock.expectOne(ME_URL).flush({ email: 'me@example.com', projects: [] }); + await p; + + service.logout(); + expect(service.isLoggedIn()).toBe(false); + expect(service.token()).toBeNull(); + expect(localStorage.getItem(LS.PLAINSPACE_ACCOUNT)).toBeNull(); + }); +}); diff --git a/src/app/features/plainspace/plainspace-account.service.ts b/src/app/features/plainspace/plainspace-account.service.ts new file mode 100644 index 0000000000..841bc81538 --- /dev/null +++ b/src/app/features/plainspace/plainspace-account.service.ts @@ -0,0 +1,65 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { LS } from '../../core/persistence/storage-keys.const'; +import { Log } from '../../core/log'; +import { PlainspaceAccount } from './plainspace-account.model'; +import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service'; +import { DEFAULT_PLAINSPACE_CFG } from '../issue/providers/plainspace/plainspace-cfg-form.const'; + +const DEFAULT_HOST = 'https://plainspace.org'; + +/** + * Holds the connected Plainspace account (a personal API token + host) and + * exposes it as signals. Persisted to localStorage (local-only, never synced — + * a PAT is per device). Used by the share-on-create flow, which needs a token + * before any provider/space exists. + */ +@Injectable({ providedIn: 'root' }) +export class PlainspaceAccountService { + private readonly _api = inject(PlainspaceApiService); + private readonly _account = signal(this._load()); + + readonly account = this._account.asReadonly(); + readonly isLoggedIn = computed(() => !!this._account()); + readonly token = computed(() => this._account()?.token ?? null); + readonly host = computed(() => this._account()?.host ?? null); + + /** + * Validates a PAT against the host (`GET /api/integration/me`) and, on + * success, stores it. Returns whether the token was accepted. + */ + async connect(token: string, host: string = DEFAULT_HOST): Promise { + const me = await firstValueFrom( + this._api.getMe$({ ...DEFAULT_PLAINSPACE_CFG, host, token }), + ); + if (!me) { + return false; + } + const account: PlainspaceAccount = { host, token, email: me.email }; + this._account.set(account); + this._save(account); + return true; + } + + logout(): void { + this._account.set(null); + localStorage.removeItem(LS.PLAINSPACE_ACCOUNT); + } + + private _load(): PlainspaceAccount | null { + const raw = localStorage.getItem(LS.PLAINSPACE_ACCOUNT); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as PlainspaceAccount; + } catch { + Log.err('Plainspace: failed to parse stored account'); + return null; + } + } + + private _save(account: PlainspaceAccount): void { + localStorage.setItem(LS.PLAINSPACE_ACCOUNT, JSON.stringify(account)); + } +} diff --git a/src/app/features/plainspace/plainspace-claim-pool.service.spec.ts b/src/app/features/plainspace/plainspace-claim-pool.service.spec.ts new file mode 100644 index 0000000000..ab451686e3 --- /dev/null +++ b/src/app/features/plainspace/plainspace-claim-pool.service.spec.ts @@ -0,0 +1,100 @@ +import { TestBed } from '@angular/core/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { firstValueFrom, of } from 'rxjs'; +import { PlainspaceClaimPoolService } from './plainspace-claim-pool.service'; +import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service'; +import { PlainspaceIssue } from '../issue/providers/plainspace/plainspace-issue.model'; +import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors'; +import { IssueService } from '../issue/issue.service'; +import { IssueProviderPlainspace } from '../issue/issue.model'; +import { SnackService } from '../../core/snack/snack.service'; + +const PLAINSPACE_PROVIDER = { + id: 'p1', + issueProviderKey: 'PLAINSPACE', + isEnabled: true, + defaultProjectId: 'proj-1', + host: 'https://plainspace.org', + spaceId: 'space-1', + token: 'pat_x', +} as IssueProviderPlainspace; + +const issue = (id: string): PlainspaceIssue => ({ + id, + title: id, + isDone: false, + updatedAt: '2026-01-01T00:00:00.000Z', + url: `https://plainspace.org/p/item/${id}`, + projectId: 'space-1', + scheduledAt: null, + isRecurring: false, +}); + +describe('PlainspaceClaimPoolService', () => { + let service: PlainspaceClaimPoolService; + let store: MockStore; + let apiSpy: jasmine.SpyObj; + let addTaskFromIssueSpy: jasmine.Spy; + let snackSpy: jasmine.SpyObj; + + beforeEach(() => { + apiSpy = jasmine.createSpyObj('PlainspaceApiService', [ + 'getUnclaimedTasks$', + 'claimTask$', + ]); + apiSpy.getUnclaimedTasks$.and.returnValue(of([issue('ps-102'), issue('ps-105')])); + apiSpy.claimTask$.and.returnValue(of(issue('ps-102'))); + addTaskFromIssueSpy = jasmine.createSpy('addTaskFromIssue').and.resolveTo('t1'); + snackSpy = jasmine.createSpyObj('SnackService', ['open']); + + TestBed.configureTestingModule({ + providers: [ + PlainspaceClaimPoolService, + provideMockStore(), + { provide: PlainspaceApiService, useValue: apiSpy }, + { provide: IssueService, useValue: { addTaskFromIssue: addTaskFromIssueSpy } }, + { provide: SnackService, useValue: snackSpy }, + ], + }); + service = TestBed.inject(PlainspaceClaimPoolService); + store = TestBed.inject(MockStore); + store.overrideSelector(selectEnabledIssueProviders, [PLAINSPACE_PROVIDER]); + store.refreshState(); + }); + + it('returns the unclaimed tasks for a shared project', async () => { + const unclaimed = await firstValueFrom(service.unclaimedTasksForProject$('proj-1')); + expect(unclaimed.map((t) => t.id)).toEqual(['ps-102', 'ps-105']); + }); + + it('returns empty when the project has no bound Plainspace provider', async () => { + const none = await firstValueFrom(service.unclaimedTasksForProject$('other-proj')); + expect(none).toEqual([]); + }); + + it('claim assigns the task and imports it as an SP task', async () => { + await service.claim('proj-1', 'ps-102'); + + expect(apiSpy.claimTask$).toHaveBeenCalledWith('ps-102', PLAINSPACE_PROVIDER); + expect(addTaskFromIssueSpy).toHaveBeenCalledTimes(1); + const arg = addTaskFromIssueSpy.calls.mostRecent().args[0]; + expect(arg.issueProviderKey).toBe('PLAINSPACE'); + expect(arg.issueDataReduced.id).toBe('ps-102'); + }); + + it('shows a success snack on a successful claim', async () => { + await service.claim('proj-1', 'ps-102'); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'SUCCESS' }), + ); + }); + + it('shows an error snack and does not import when the claim fails', async () => { + apiSpy.claimTask$.and.returnValue(of(null)); + await service.claim('proj-1', 'ps-102'); + expect(addTaskFromIssueSpy).not.toHaveBeenCalled(); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + }); +}); diff --git a/src/app/features/plainspace/plainspace-claim-pool.service.ts b/src/app/features/plainspace/plainspace-claim-pool.service.ts new file mode 100644 index 0000000000..e2b6c1f499 --- /dev/null +++ b/src/app/features/plainspace/plainspace-claim-pool.service.ts @@ -0,0 +1,109 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable, Subject, combineLatest, firstValueFrom, of } from 'rxjs'; +import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { selectEnabledIssueProviders } from '../issue/store/issue-provider.selectors'; +import { IssueProvider, IssueProviderPlainspace } from '../issue/issue.model'; +import { PlainspaceApiService } from '../issue/providers/plainspace/plainspace-api.service'; +import { IssueService } from '../issue/issue.service'; +import { SnackService } from '../../core/snack/snack.service'; +import { T } from '../../t.const'; +import { PlainspaceSharedTask } from './plainspace-shared-task.model'; + +/** + * The "claim pool": unclaimed (unassigned) Plainspace tasks for a shared + * project, shown read-only. Claiming assigns the task to the signed-in user in + * Plainspace and imports it as a first-class SP task — the only way unclaimed + * work enters SP (docs §7). A project is "shared" when it has a bound, enabled + * `PLAINSPACE` provider (`defaultProjectId === projectId`). + */ +@Injectable({ providedIn: 'root' }) +export class PlainspaceClaimPoolService { + private _store = inject(Store); + private _plainspaceApiService = inject(PlainspaceApiService); + private _issueService = inject(IssueService); + private _snackService = inject(SnackService); + + // Pings the pool to re-fetch after a claim so the claimed task leaves the pool. + private readonly _refresh$ = new Subject(); + + unclaimedTasksForProject$(projectId: string): Observable { + // Editing any issue provider re-emits the list; collapse to this project's + // bound provider and only re-fetch when its identity actually changes... + const provider$ = this._store.select(selectEnabledIssueProviders).pipe( + map((providers) => this._findProvider(providers, projectId)), + distinctUntilChanged( + (a, b) => a?.id === b?.id && a?.token === b?.token && a?.spaceId === b?.spaceId, + ), + ); + // ...or on an explicit refresh (after a claim), so the claimed task leaves + // the pool even though the provider itself is unchanged. + return combineLatest([provider$, this._refresh$.pipe(startWith(undefined))]).pipe( + switchMap(([provider]) => + provider ? this._unclaimedForProvider$(provider) : of([]), + ), + ); + } + + /** + * Claims an unclaimed task: assigns it to me in Plainspace, then imports it as + * an SP task (added to the project backlog). + */ + async claim(projectId: string, taskId: string): Promise { + const providers = await firstValueFrom( + this._store.select(selectEnabledIssueProviders), + ); + const provider = this._findProvider(providers, projectId); + if (!provider) { + return; + } + const claimed = await firstValueFrom( + this._plainspaceApiService.claimTask$(taskId, provider), + ); + if (claimed) { + await this._issueService.addTaskFromIssue({ + issueDataReduced: claimed, + issueProviderId: provider.id, + issueProviderKey: 'PLAINSPACE', + isAddToBacklog: true, + }); + this._snackService.open({ type: 'SUCCESS', msg: T.PLAINSPACE.CLAIM_SUCCESS }); + } else { + // `claimTask$` fails soft to null — most often the race where someone else + // claimed first (or an outage). One friendly message covers both; the + // refresh below drops a now-claimed task from the pool. + // shortcut: generic message — distinguish 409 "taken" from offline once the + // server contract pins the status code. + this._snackService.open({ type: 'ERROR', msg: T.PLAINSPACE.CLAIM_FAILED }); + } + this._refresh$.next(); + } + + private _findProvider( + providers: IssueProvider[], + projectId: string, + ): IssueProviderPlainspace | undefined { + return providers.find( + (p): p is IssueProviderPlainspace => + p.issueProviderKey === 'PLAINSPACE' && p.defaultProjectId === projectId, + ); + } + + private _unclaimedForProvider$( + provider: IssueProviderPlainspace, + ): Observable { + return this._plainspaceApiService.getUnclaimedTasks$(provider).pipe( + map((issues) => + issues.map( + (issue): PlainspaceSharedTask => ({ + id: issue.id, + title: issue.title, + isDone: issue.isDone, + url: issue.url, + isRecurring: issue.isRecurring, + }), + ), + ), + ); + } +} diff --git a/src/app/features/plainspace/plainspace-shared-task.model.ts b/src/app/features/plainspace/plainspace-shared-task.model.ts new file mode 100644 index 0000000000..dc6672571d --- /dev/null +++ b/src/app/features/plainspace/plainspace-shared-task.model.ts @@ -0,0 +1,17 @@ +/** + * The view-model for an unclaimed Plainspace task shown in the claim pool. + * + * These are deliberately NOT Super Productivity `Task`s: unclaimed tasks are + * shown read-only and never enter the SP task store / op-log sync until claimed. + * Mapped from `PlainspaceIssue` (the API shape) in `PlainspaceClaimPoolService`. + */ +export interface PlainspaceSharedTask { + id: string; + title: string; + isDone: boolean; + /** Absolute link to open the task in the Plainspace web UI. */ + url?: string | null; + /** Repeats in Plainspace — flagged in the pool so claiming a recurring + * commitment is visible up front. The cadence stays Plainspace-side. */ + isRecurring: boolean; +} diff --git a/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.html b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.html new file mode 100644 index 0000000000..d1c747a981 --- /dev/null +++ b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.html @@ -0,0 +1,48 @@ +

{{ T.PLAINSPACE.SPACE_PICKER.TITLE | translate }}

+ + + @if (isLoading()) { +

{{ T.PLAINSPACE.SPACE_PICKER.LOADING | translate }}

+ } @else if (hasError()) { +

{{ T.PLAINSPACE.SPACE_PICKER.ERROR | translate }}

+ } @else if (spaces().length) { +

{{ T.PLAINSPACE.SPACE_PICKER.INTRO | translate }}

+ + + {{ T.PLAINSPACE.SPACE_PICKER.SELECT_LABEL | translate }} + + @for (space of spaces(); track space.id) { + {{ space.name }} + } + + + } @else { +

{{ T.PLAINSPACE.SPACE_PICKER.NONE | translate }}

+ } +
+ + + + + @if (spaces().length) { + + } + diff --git a/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.scss b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.scss new file mode 100644 index 0000000000..cabf4d7efa --- /dev/null +++ b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.scss @@ -0,0 +1,17 @@ +:host { + display: block; + max-width: 440px; +} + +.intro { + margin-top: 0; +} + +.space-field { + width: 100%; +} + +.error { + margin-top: 0; + color: var(--c-warn); +} diff --git a/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.spec.ts b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.spec.ts new file mode 100644 index 0000000000..b968deb64e --- /dev/null +++ b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.spec.ts @@ -0,0 +1,102 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { PlainspaceSpacePickerDialogComponent } from './plainspace-space-picker-dialog.component'; +import { PlainspaceAccountService } from '../plainspace-account.service'; +import { PlainspaceApiService } from '../../issue/providers/plainspace/plainspace-api.service'; + +const SPACES = [ + { id: 's1', name: 'Space One', slug: 'one' }, + { id: 's2', name: 'Space Two', slug: 'two' }, +]; +const ACCOUNT = { host: 'https://plainspace.org', token: 'pat_abc', email: 'a@b.c' }; + +describe('PlainspaceSpacePickerDialogComponent', () => { + let component: PlainspaceSpacePickerDialogComponent; + let fixture: ComponentFixture; + let dialogRef: jasmine.SpyObj>; + let accountStub: { account: jasmine.Spy }; + let apiStub: { getSpaces$: jasmine.Spy }; + + beforeEach(async () => { + dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + accountStub = { account: jasmine.createSpy('account').and.returnValue(ACCOUNT) }; + apiStub = { getSpaces$: jasmine.createSpy('getSpaces$').and.returnValue(of(SPACES)) }; + + await TestBed.configureTestingModule({ + imports: [ + PlainspaceSpacePickerDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: PlainspaceAccountService, useValue: accountStub }, + { provide: PlainspaceApiService, useValue: apiStub }, + ], + }).compileComponents(); + }); + + const createComponent = (): void => { + fixture = TestBed.createComponent(PlainspaceSpacePickerDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + it('loads the spaces and preselects the first', async () => { + createComponent(); + await fixture.whenStable(); + expect(apiStub.getSpaces$).toHaveBeenCalled(); + expect(component.spaces()).toEqual(SPACES); + expect(component.selectedSpaceId).toBe('s1'); + expect(component.isLoading()).toBe(false); + }); + + it('link() closes with the selected existing space', async () => { + createComponent(); + await fixture.whenStable(); + component.selectedSpaceId = 's2'; + component.link(); + expect(dialogRef.close).toHaveBeenCalledWith({ action: 'link', spaceId: 's2' }); + }); + + it('link() does nothing without a selection', async () => { + apiStub.getSpaces$.and.returnValue(of([])); + createComponent(); + await fixture.whenStable(); + component.selectedSpaceId = null; + component.link(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('createNew() closes with the create action', () => { + createComponent(); + component.createNew(); + expect(dialogRef.close).toHaveBeenCalledWith({ action: 'create' }); + }); + + it('cancel() closes with no result', () => { + createComponent(); + component.cancel(); + expect(dialogRef.close).toHaveBeenCalledWith(); + }); + + it('shows the error state (not empty) when the spaces request fails', async () => { + apiStub.getSpaces$.and.returnValue(of(null)); + createComponent(); + await fixture.whenStable(); + expect(component.hasError()).toBe(true); + expect(component.isLoading()).toBe(false); + expect(component.spaces()).toEqual([]); + }); + + it('closes immediately when no account is connected', async () => { + accountStub.account.and.returnValue(null); + createComponent(); + await fixture.whenStable(); + expect(apiStub.getSpaces$).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith(); + }); +}); diff --git a/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.ts b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.ts new file mode 100644 index 0000000000..8e28a3f272 --- /dev/null +++ b/src/app/features/plainspace/space-picker-dialog/plainspace-space-picker-dialog.component.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { + MatDialogActions, + MatDialogContent, + MatDialogRef, + MatDialogTitle, +} from '@angular/material/dialog'; +import { FormsModule } from '@angular/forms'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatSelect } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; +import { MatButton } from '@angular/material/button'; +import { TranslatePipe } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import { T } from '../../../t.const'; +import { PlainspaceAccountService } from '../plainspace-account.service'; +import { + PlainspaceApiService, + PlainspaceSpace, +} from '../../issue/providers/plainspace/plainspace-api.service'; +import { DEFAULT_PLAINSPACE_CFG } from '../../issue/providers/plainspace/plainspace-cfg-form.const'; + +/** What the user chose: link one of their existing spaces, or create a new one. */ +export type PlainspaceSpaceChoice = + | { action: 'create' } + | { action: 'link'; spaceId: string }; + +/** + * Lets the user link an existing Plainspace space (so the tasks already assigned + * to them import) instead of always creating a new one. Assumes a connected + * account (the share flow ensures that first); resolves to the chosen action, or + * `undefined` if cancelled. + */ +@Component({ + selector: 'plainspace-space-picker-dialog', + templateUrl: './plainspace-space-picker-dialog.component.html', + styleUrls: ['./plainspace-space-picker-dialog.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatDialogTitle, + MatDialogContent, + MatDialogActions, + FormsModule, + MatFormField, + MatLabel, + MatSelect, + MatOption, + MatButton, + TranslatePipe, + ], +}) +export class PlainspaceSpacePickerDialogComponent { + private _dialogRef = + inject>( + MatDialogRef, + ); + private _accountService = inject(PlainspaceAccountService); + private _api = inject(PlainspaceApiService); + + readonly T = T; + readonly spaces = signal([]); + readonly isLoading = signal(true); + readonly hasError = signal(false); + selectedSpaceId: string | null = null; + + constructor() { + void this._loadSpaces(); + } + + link(): void { + if (!this.selectedSpaceId) { + return; + } + this._dialogRef.close({ action: 'link', spaceId: this.selectedSpaceId }); + } + + createNew(): void { + this._dialogRef.close({ action: 'create' }); + } + + cancel(): void { + this._dialogRef.close(); + } + + private async _loadSpaces(): Promise { + const account = this._accountService.account(); + if (!account) { + // Should not happen — the share flow connects first — but bail safely. + this._dialogRef.close(); + return; + } + const spaces = await firstValueFrom( + this._api.getSpaces$({ + ...DEFAULT_PLAINSPACE_CFG, + host: account.host, + token: account.token, + }), + ); + // null = the request failed (vs an empty list = genuinely no spaces yet). + if (spaces === null) { + this.hasError.set(true); + } else { + this.spaces.set(spaces); + this.selectedSpaceId = spaces[0]?.id ?? null; + } + this.isLoading.set(false); + } +} diff --git a/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts b/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts index 7bf4bb00e9..4f69bde69c 100644 --- a/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts +++ b/src/app/features/project/dialogs/create-project/dialog-create-project.component.ts @@ -18,7 +18,10 @@ import { FormlyFieldConfig, FormlyFormOptions, FormlyModule } from '@ngx-formly/ import { ProjectService } from '../../project.service'; import { DEFAULT_PROJECT } from '../../project.const'; import { JiraCfg } from '../../../issue/providers/jira/jira.model'; -import { CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG } from '../../project-form-cfg.const'; +import { + CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG, + CreateProjectFormModel, +} from '../../project-form-cfg.const'; import { SS } from '../../../../core/persistence/storage-keys.const'; import { Subscription } from 'rxjs'; import { @@ -36,6 +39,7 @@ import { removeDebounceFromFormItems } from '../../../../util/remove-debounce-fr import { MatButton } from '@angular/material/button'; import { TranslatePipe } from '@ngx-translate/core'; import { Log } from '../../../../core/log'; +import { PlainspaceShareService } from '../../../issue/providers/plainspace/plainspace-share.service'; @Component({ selector: 'dialog-create-project', @@ -56,6 +60,7 @@ import { Log } from '../../../../core/log'; export class DialogCreateProjectComponent implements OnInit, OnDestroy { private _project = inject(MAT_DIALOG_DATA); private _projectService = inject(ProjectService); + private _plainspaceShareService = inject(PlainspaceShareService); private _matDialogRef = inject>(MatDialogRef); @@ -118,6 +123,8 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy { const projectDataToSave: Project | Partial = { ...this.projectData, }; + // Never persist the transient share flag (not part of the Project model). + delete (projectDataToSave as Partial).isShareOnPlainspace; if (this._isSaveTmpProject) { saveToSessionStorage(SS.PROJECT_TMP, projectDataToSave); } @@ -145,12 +152,24 @@ export class DialogCreateProjectComponent implements OnInit, OnDestroy { const projectDataToSave: Project | Partial = { ...this.projectData, }; + // `isShareOnPlainspace` is a transient form-only flag — read it, then strip + // it so it is never persisted onto the Project entity. + const isShareOnPlainspace = !!(projectDataToSave as Partial) + .isShareOnPlainspace; + delete (projectDataToSave as Partial).isShareOnPlainspace; let newProjectId: string | undefined; if (projectDataToSave.id) { this._projectService.update(projectDataToSave.id, projectDataToSave); } else { newProjectId = this._projectService.add(projectDataToSave); + if (isShareOnPlainspace && newProjectId) { + // Fire-and-forget: provision a Plainspace space + bound issue provider. + this._plainspaceShareService.shareProjectOnPlainspace( + newProjectId, + projectDataToSave.title || '', + ); + } } this._isSaveTmpProject = false; sessionStorage.removeItem(SS.PROJECT_TMP); diff --git a/src/app/features/project/project-form-cfg.const.ts b/src/app/features/project/project-form-cfg.const.ts index 11296c2bb6..4b7f0ab43f 100644 --- a/src/app/features/project/project-form-cfg.const.ts +++ b/src/app/features/project/project-form-cfg.const.ts @@ -2,44 +2,65 @@ import { ConfigFormSection } from '../config/global-config.model'; import { T } from '../../t.const'; import { Project } from './project.model'; -export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection = { - // TODO translate - title: 'Project Settings & Theme', - key: 'basic', +// The create-project form carries one transient field beyond the Project model: +// `isShareOnPlainspace` (read on submit, then stripped — never persisted). Typing +// the section with it keeps the field key type-safe instead of an `as any` cast. +export type CreateProjectFormModel = Project & { isShareOnPlainspace?: boolean }; - help: `Very basic settings for your project.`, +export const CREATE_PROJECT_BASIC_CONFIG_FORM_CONFIG: ConfigFormSection = + { + // TODO translate + title: 'Project Settings & Theme', + key: 'basic', - items: [ - { - key: 'title', - type: 'input', - templateOptions: { - required: true, - label: T.F.PROJECT.FORM_BASIC.L_TITLE, + help: `Very basic settings for your project.`, + + items: [ + { + key: 'title', + type: 'input', + templateOptions: { + required: true, + label: T.F.PROJECT.FORM_BASIC.L_TITLE, + }, }, - }, - { - key: 'theme.primary' as any, - type: 'color', - templateOptions: { - label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR, + { + key: 'theme.primary' as any, + type: 'color', + templateOptions: { + label: T.F.PROJECT.FORM_THEME.L_THEME_COLOR, + }, }, - }, - { - key: 'icon', - type: 'icon', - templateOptions: { - label: T.F.TAG.FORM_BASIC.L_ICON, - description: T.G.ICON_INP_DESCRIPTION, + { + key: 'icon', + type: 'icon', + templateOptions: { + label: T.F.TAG.FORM_BASIC.L_ICON, + description: T.G.ICON_INP_DESCRIPTION, + }, }, - }, - { - key: 'isEnableBacklog', - type: 'checkbox', - defaultValue: false, - templateOptions: { - label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG, + { + key: 'isEnableBacklog', + type: 'checkbox', + defaultValue: false, + templateOptions: { + label: T.F.PROJECT.FORM_BASIC.L_ENABLE_BACKLOG, + }, }, - }, - ], -}; + { + // Transient form-only field (not persisted on the Project). When checked + // on create, the dialog provisions a Plainspace space + bound issue + // provider. See docs/plainspace-integration-plan.md §6. + // Create-only: editing an existing project (model has an id) can't + // provision sharing here, so hide it rather than show a dead control. + key: 'isShareOnPlainspace', + type: 'checkbox', + defaultValue: false, + hideExpression: '!!model.id', + templateOptions: { + label: T.PLAINSPACE.SHARE_LABEL, + description: T.PLAINSPACE.SHARE_DESCRIPTION, + }, + }, + ], + }; diff --git a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html index 4093eb239a..b14f947dba 100644 --- a/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html +++ b/src/app/features/tasks/task/task-hover-controls/task-hover-controls.component.html @@ -50,7 +50,12 @@ } @if ( - !task().notes && (!task().issueId || task().issueType === ICAL_TYPE) && !isSelected() + !task().notes && + (!task().issueId || + task().issueType === ICAL_TYPE || + task().issueType === PLAINSPACE_TYPE) && + !task().issueWasUpdated && + !isSelected() ) {