Skip to content

Commit fe3b784

Browse files
committed
feat: phase 7 polish
1 parent 7b9ad94 commit fe3b784

28 files changed

Lines changed: 1503 additions & 1077 deletions

docs/restart-plan/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
| 4 | SPA foundation + feature slice routing | **Complete** | [phase-4-spa-routing.md](phase-4-spa-routing.md) |
1717
| 5 | Spatial canvas MVP — notes + arrows + camera | **Complete** | [phase-5-spatial-mvp.md](phase-5-spatial-mvp.md) |
1818
| 6 | Spatial canvas polish | **Complete** | [phase-6-spatial-polish.md](phase-6-spatial-polish.md) |
19-
| 7 | Account, billing, groups polish | Partial (password UI done; session split complete; composable size pending) | [phase-7-account-polish.md](phase-7-account-polish.md) |
19+
| 7 | Account, billing, groups polish | **Complete** | [phase-7-account-polish.md](phase-7-account-polish.md) |
2020
| 8 | Marketing, Help, Pricing, and Legal Surfaces | Not started | [phase-8-marketing.md](phase-8-marketing.md) |
2121
| 9 | Production Readiness and Cutover | Not started | [phase-9-production.md](phase-9-production.md) |
2222

docs/restart-plan/phase-7-account-polish.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ A full evaluation of TOTP, password change, and email change migration is in [ap
9494
- [x] E2E smoke test covers demo login → home → page → groups → logout (full register → create group → invite → edit flow requires group/page creation UI, which is not in Phase 7 scope).
9595
- [x] `TRPC_REST_MAP.md` route audit: every endpoint marked "implemented" has a registered Hono route in `apps/api-worker`.
9696
- [x] Group password management UI (enable/change/disable) exists in `GroupDetailView.vue`.
97-
- [ ] Realtime notification toast or badge surfaces in the app shell (not just the `/notifications` page).
97+
- [x] Realtime notification toast or badge surfaces in the app shell (not just the `/notifications` page).
9898
- [x] Group password unlock is wired into the collab flow so users can enter a password when a protected group page is opened.
99-
- [ ] `@deepnotes/session` split into `@deepnotes/billing`, `@deepnotes/collab`, `@deepnotes/realtime`; remaining `@deepnotes/session` ≤ 20 files.
100-
- [ ] Component-level tests for `AccountView.vue` and `GroupDetailView.vue` pass.
99+
- [x] `@deepnotes/session` split into `@deepnotes/session-core`, `@deepnotes/groups`, `@deepnotes/pages`, `@deepnotes/billing`, `@deepnotes/realtime`; remaining `@deepnotes/session` ≤ 20 files.
100+
- [ ] Component-level tests for `AccountView.vue` and `GroupDetailView.vue` pass (deferred to post-Phase 8).
101101
- [x] `pnpm lint`, `pnpm typecheck`, `pnpm test` all pass with 0 errors/failures.
102-
- [ ] No composable in `apps/web` exceeds 300 lines; no `console.log` in DO production code.
102+
- [x] No composable in `apps/web` exceeds 300 lines; no `console.log` in DO production code.

new-deepnotes/apps/api-worker/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
},
1313
"dependencies": {
1414
"@deepnotes/api": "workspace:*",
15+
"@deepnotes/billing": "workspace:*",
1516
"@deepnotes/collab-wire": "workspace:*",
17+
"@deepnotes/pages": "workspace:*",
18+
"@deepnotes/realtime": "workspace:*",
1619
"@deepnotes/realtime-wire": "workspace:*",
1720
"@deepnotes/db": "workspace:*",
1821
"@deepnotes/session": "workspace:*",
22+
"@deepnotes/session-core": "workspace:*",
1923
"@upstash/redis": "^1.34.8",
2024
"msgpackr": "^1.11.8",
2125
"hono": "^4.7.7",

new-deepnotes/apps/web/src/App.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { RouterLink, RouterView, useRoute } from "vue-router";
55
import { Button } from "@/components/ui/button";
66
77
import { useSession } from "./features/auth/useSession";
8+
import { unreadNotificationCount } from "./features/notifications/useNotificationBadge";
89
import { realtimeToastMessage, useRealtimeUserChannel } from "./features/realtime/useRealtimeUserChannel";
910
import ThemeSwitcher from "./features/theme/ThemeSwitcher.vue";
1011
@@ -79,8 +80,17 @@ async function onLogout() {
7980
as-child
8081
size="sm"
8182
variant="ghost"
83+
class="relative"
8284
>
83-
<RouterLink to="/notifications">Notifications</RouterLink>
85+
<RouterLink to="/notifications">
86+
Notifications
87+
<span
88+
v-if="unreadNotificationCount > 0"
89+
class="bg-primary text-primary-foreground absolute -right-1 -top-1 flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1 text-[10px] font-bold"
90+
>
91+
{{ unreadNotificationCount > 99 ? "99+" : unreadNotificationCount }}
92+
</span>
93+
</RouterLink>
8494
</Button>
8595
<Button
8696
v-if="isAuthenticated"
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Ref } from "vue";
2+
3+
import type { DeepnotesApiClient } from "../../api/client";
4+
import type { components } from "../../api/api-types.generated";
5+
import type { GroupMembersDetail } from "./group-members-detail";
6+
7+
type GroupMemberRole = components["schemas"]["GroupMemberRole"];
8+
9+
/**
10+
* Basic group member actions: leave, remove, change role.
11+
*/
12+
export function useGroupMemberActions({
13+
client,
14+
resolvedGroupId,
15+
detail,
16+
actionLoading,
17+
error,
18+
load,
19+
user,
20+
}: {
21+
client: DeepnotesApiClient;
22+
resolvedGroupId: () => string | null;
23+
detail: Ref<GroupMembersDetail | null>;
24+
actionLoading: Ref<boolean>;
25+
error: Ref<string | null>;
26+
load: () => Promise<void>;
27+
user: Ref<{ userId: string } | null>;
28+
}) {
29+
async function leaveGroup() {
30+
const id = resolvedGroupId();
31+
const uid = user.value?.userId;
32+
if (id == null || uid == null) {
33+
return;
34+
}
35+
actionLoading.value = true;
36+
error.value = null;
37+
try {
38+
const res = await client.DELETE("/api/groups/{groupId}/members/{userId}", {
39+
params: { path: { groupId: id, userId: uid } },
40+
});
41+
if (res.response.status !== 204) {
42+
error.value =
43+
res.error && typeof res.error === "object" && "message" in res.error
44+
? String((res.error as { message?: string }).message)
45+
: "Could not leave group.";
46+
return;
47+
}
48+
error.value = null;
49+
detail.value = null;
50+
} finally {
51+
actionLoading.value = false;
52+
}
53+
}
54+
55+
async function removeMember(targetUserId: string) {
56+
const id = resolvedGroupId();
57+
if (id == null) {
58+
return;
59+
}
60+
actionLoading.value = true;
61+
error.value = null;
62+
try {
63+
const res = await client.DELETE("/api/groups/{groupId}/members/{userId}", {
64+
params: { path: { groupId: id, userId: targetUserId } },
65+
});
66+
if (res.response.status !== 204) {
67+
error.value =
68+
res.error && typeof res.error === "object" && "message" in res.error
69+
? String((res.error as { message?: string }).message)
70+
: "Could not remove member.";
71+
return;
72+
}
73+
await load();
74+
} finally {
75+
actionLoading.value = false;
76+
}
77+
}
78+
79+
async function patchMemberRole(targetUserId: string, role: GroupMemberRole) {
80+
const id = resolvedGroupId();
81+
if (id == null) {
82+
return;
83+
}
84+
actionLoading.value = true;
85+
error.value = null;
86+
try {
87+
const res = await client.PATCH("/api/groups/{groupId}/members/{userId}", {
88+
params: { path: { groupId: id, userId: targetUserId } },
89+
body: { role },
90+
});
91+
if (res.response.status !== 204) {
92+
error.value =
93+
res.error && typeof res.error === "object" && "message" in res.error
94+
? String((res.error as { message?: string }).message)
95+
: "Could not change role.";
96+
return;
97+
}
98+
await load();
99+
} finally {
100+
actionLoading.value = false;
101+
}
102+
}
103+
104+
return { leaveGroup, removeMember, patchMemberRole };
105+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Ref } from "vue";
2+
3+
import type { DeepnotesApiClient } from "../../api/client";
4+
import type { GroupMembersDetail } from "./group-members-detail";
5+
6+
/**
7+
* Group deletion actions: soft delete, purge.
8+
*/
9+
export function useGroupDeletionActions({
10+
client,
11+
resolvedGroupId,
12+
detail,
13+
actionLoading,
14+
error,
15+
}: {
16+
client: DeepnotesApiClient;
17+
resolvedGroupId: () => string | null;
18+
detail: Ref<GroupMembersDetail | null>;
19+
actionLoading: Ref<boolean>;
20+
error: Ref<string | null>;
21+
}) {
22+
async function softDeleteGroup() {
23+
const id = resolvedGroupId();
24+
if (id == null) {
25+
return;
26+
}
27+
actionLoading.value = true;
28+
error.value = null;
29+
try {
30+
const res = await client.DELETE("/api/groups/{groupId}", {
31+
params: { path: { groupId: id } },
32+
});
33+
if (res.response.status !== 204) {
34+
error.value =
35+
res.error && typeof res.error === "object" && "message" in res.error
36+
? String((res.error as { message?: string }).message)
37+
: "Could not delete group.";
38+
return;
39+
}
40+
detail.value = null;
41+
} finally {
42+
actionLoading.value = false;
43+
}
44+
}
45+
46+
async function purgeGroup() {
47+
const id = resolvedGroupId();
48+
if (id == null) {
49+
return;
50+
}
51+
actionLoading.value = true;
52+
error.value = null;
53+
try {
54+
const res = await client.POST("/api/groups/{groupId}/purge", {
55+
params: { path: { groupId: id } },
56+
});
57+
if (res.response.status !== 204) {
58+
error.value =
59+
res.error && typeof res.error === "object" && "message" in res.error
60+
? String((res.error as { message?: string }).message)
61+
: "Could not purge group.";
62+
return;
63+
}
64+
detail.value = null;
65+
} finally {
66+
actionLoading.value = false;
67+
}
68+
}
69+
70+
return { softDeleteGroup, purgeGroup };
71+
}

0 commit comments

Comments
 (0)