Skip to content

Commit 97ef643

Browse files
committed
feat: add private following inbox
1 parent 0c7c669 commit 97ef643

11 files changed

Lines changed: 1076 additions & 450 deletions

File tree

convex/_generated/api.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
174174
import type * as publisherAbuseTemporalScan from "../publisherAbuseTemporalScan.js";
175175
import type * as publisherFollows from "../publisherFollows.js";
176176
import type * as publisherActivity from "../publisherActivity.js";
177+
import type * as publisherActivityInbox from "../publisherActivityInbox.js";
177178
import type * as publishers from "../publishers.js";
178179
import type * as rateLimits from "../rateLimits.js";
179180
import type * as retention from "../retention.js";
@@ -378,6 +379,7 @@ declare const fullApi: ApiFromModules<{
378379
publisherAbuseTemporalScan: typeof publisherAbuseTemporalScan;
379380
publisherFollows: typeof publisherFollows;
380381
publisherActivity: typeof publisherActivity;
382+
publisherActivityInbox: typeof publisherActivityInbox;
381383
publishers: typeof publishers;
382384
rateLimits: typeof rateLimits;
383385
retention: typeof retention;

convex/lib/retentionPolicy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export const RETENTION_POLICIES = {
242242
publisherFollows: permanent("User publisher follow preference records."),
243243
publisherActivity: permanent("Granular public publisher release activity events."),
244244
publisherActivityGroups: permanent("Coalesced public publisher release activity groups."),
245+
publisherActivityInboxState: permanent("Per-user following inbox read frontiers."),
245246
auditLogs: permanent("Audit logs are durable compliance/security history."),
246247
systemSettings: permanent("Durable operator-controlled system settings."),
247248
skillsShCatalogControls: permanent("Durable skills.sh catalog operator controls."),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* @vitest-environment node */
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("./functions", () => ({
5+
mutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
6+
query: (def: { handler: unknown }) => ({ _handler: def.handler }),
7+
}));
8+
vi.mock("./lib/access", () => ({ requireUser: vi.fn() }));
9+
10+
const { requireUser } = await import("./lib/access");
11+
const { getMine, markSeenThrough } = await import("./publisherActivityInbox");
12+
13+
type WrappedHandler<TArgs, TResult> = {
14+
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
15+
};
16+
17+
const getMineHandler = (getMine as unknown as WrappedHandler<Record<string, never>, unknown>)
18+
._handler;
19+
const markSeenThroughHandler = (
20+
markSeenThrough as unknown as WrappedHandler<{ groupId: string }, unknown>
21+
)._handler;
22+
23+
function makeCtx({
24+
group = { _id: "publisherActivityGroups:1", publisherId: "publishers:1", sortKey: "200:b" },
25+
follow = { _id: "publisherFollows:1" } as { _id: string } | null,
26+
state = null as null | {
27+
_id: string;
28+
userId: string;
29+
seenThroughSortKey: string;
30+
updatedAt: number;
31+
},
32+
} = {}) {
33+
const insert = vi.fn(async () => "publisherActivityInboxState:1");
34+
const patch = vi.fn();
35+
const query = vi.fn((table: string) => ({
36+
withIndex: vi.fn((_index: string, build: (q: unknown) => unknown) => {
37+
const q = { eq: vi.fn().mockReturnThis() };
38+
build(q);
39+
return { unique: vi.fn(async () => (table === "publisherFollows" ? follow : state)) };
40+
}),
41+
}));
42+
return {
43+
db: { get: vi.fn(async () => group), insert, patch, query },
44+
insert,
45+
patch,
46+
};
47+
}
48+
49+
afterEach(() => vi.restoreAllMocks());
50+
51+
describe("publisher activity inbox state", () => {
52+
it("returns an empty read frontier for a new inbox", async () => {
53+
vi.mocked(requireUser).mockResolvedValue({ userId: "users:1" } as never);
54+
const result = await getMineHandler(makeCtx(), {});
55+
expect(result).toEqual({ ok: true, seenThroughSortKey: null, updatedAt: null });
56+
});
57+
58+
it("creates a seen-through frontier for a followed activity group", async () => {
59+
vi.mocked(requireUser).mockResolvedValue({ userId: "users:1" } as never);
60+
const ctx = makeCtx();
61+
const result = await markSeenThroughHandler(ctx, { groupId: "publisherActivityGroups:1" });
62+
expect(ctx.insert).toHaveBeenCalledWith("publisherActivityInboxState", {
63+
userId: "users:1",
64+
seenThroughSortKey: "200:b",
65+
updatedAt: expect.any(Number),
66+
});
67+
expect(result).toEqual({ ok: true, seenThroughSortKey: "200:b" });
68+
});
69+
70+
it("never moves the frontier backwards", async () => {
71+
vi.mocked(requireUser).mockResolvedValue({ userId: "users:1" } as never);
72+
const ctx = makeCtx({
73+
state: {
74+
_id: "publisherActivityInboxState:1",
75+
userId: "users:1",
76+
seenThroughSortKey: "300:c",
77+
updatedAt: 1,
78+
},
79+
});
80+
const result = await markSeenThroughHandler(ctx, { groupId: "publisherActivityGroups:1" });
81+
expect(ctx.patch).not.toHaveBeenCalled();
82+
expect(result).toEqual({ ok: true, seenThroughSortKey: "300:c" });
83+
});
84+
85+
it("does not reveal groups from publishers the user no longer follows", async () => {
86+
vi.mocked(requireUser).mockResolvedValue({ userId: "users:1" } as never);
87+
const ctx = makeCtx({ follow: null });
88+
await expect(
89+
markSeenThroughHandler(ctx, { groupId: "publisherActivityGroups:1" }),
90+
).rejects.toThrow("Publisher activity group not found");
91+
expect(ctx.insert).not.toHaveBeenCalled();
92+
});
93+
});

convex/publisherActivityInbox.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ConvexError, v } from "convex/values";
2+
import type { Doc, Id } from "./_generated/dataModel";
3+
import type { MutationCtx, QueryCtx } from "./_generated/server";
4+
import { mutation, query } from "./functions";
5+
import { requireUser } from "./lib/access";
6+
7+
async function getStateForUser(ctx: QueryCtx | MutationCtx, userId: Id<"users">) {
8+
return await ctx.db
9+
.query("publisherActivityInboxState")
10+
.withIndex("by_user", (q) => q.eq("userId", userId))
11+
.unique();
12+
}
13+
14+
async function requireFollowedGroup(
15+
ctx: MutationCtx,
16+
userId: Id<"users">,
17+
groupId: Id<"publisherActivityGroups">,
18+
) {
19+
const group = await ctx.db.get(groupId);
20+
if (!group) throw new ConvexError("Publisher activity group not found");
21+
const follow = await ctx.db
22+
.query("publisherFollows")
23+
.withIndex("by_follower_publisher", (q) =>
24+
q.eq("followerUserId", userId).eq("publisherId", group.publisherId),
25+
)
26+
.unique();
27+
if (!follow) throw new ConvexError("Publisher activity group not found");
28+
return group;
29+
}
30+
31+
export const getMine = query({
32+
args: {},
33+
handler: async (ctx) => {
34+
const { userId } = await requireUser(ctx);
35+
const state = await getStateForUser(ctx, userId);
36+
return {
37+
ok: true as const,
38+
seenThroughSortKey: state?.seenThroughSortKey ?? null,
39+
updatedAt: state?.updatedAt ?? null,
40+
};
41+
},
42+
});
43+
44+
export const markSeenThrough = mutation({
45+
args: { groupId: v.id("publisherActivityGroups") },
46+
handler: async (ctx, args) => {
47+
const { userId } = await requireUser(ctx);
48+
const group = await requireFollowedGroup(ctx, userId, args.groupId);
49+
const existing = await getStateForUser(ctx, userId);
50+
if (existing && existing.seenThroughSortKey >= group.sortKey) {
51+
return { ok: true as const, seenThroughSortKey: existing.seenThroughSortKey };
52+
}
53+
54+
const updatedAt = Date.now();
55+
if (existing) {
56+
await ctx.db.patch(existing._id, { seenThroughSortKey: group.sortKey, updatedAt });
57+
} else {
58+
await ctx.db.insert("publisherActivityInboxState", {
59+
userId,
60+
seenThroughSortKey: group.sortKey,
61+
updatedAt,
62+
});
63+
}
64+
return { ok: true as const, seenThroughSortKey: group.sortKey };
65+
},
66+
});
67+
68+
export async function deletePublisherActivityInboxStateForUser(
69+
ctx: MutationCtx,
70+
userId: Id<"users">,
71+
) {
72+
const state = (await getStateForUser(ctx, userId)) as Doc<"publisherActivityInboxState"> | null;
73+
if (state) await ctx.db.delete(state._id);
74+
return Boolean(state);
75+
}

convex/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2992,6 +2992,12 @@ const publisherActivityGroups = defineTable({
29922992
.index("by_publisher_and_sortKey", ["publisherId", "sortKey"])
29932993
.index("by_publisher_and_batchKey", ["publisherId", "batchKey"]);
29942994

2995+
const publisherActivityInboxState = defineTable({
2996+
userId: v.id("users"),
2997+
seenThroughSortKey: v.string(),
2998+
updatedAt: v.number(),
2999+
}).index("by_user", ["userId"]);
3000+
29953001
const auditLogs = defineTable({
29963002
actorUserId: v.optional(v.id("users")),
29973003
action: v.string(),
@@ -4232,6 +4238,7 @@ export default defineSchema({
42324238
publisherFollows,
42334239
publisherActivity,
42344240
publisherActivityGroups,
4241+
publisherActivityInboxState,
42354242
auditLogs,
42364243
systemSettings,
42374244
skillsShCatalogControls,

convex/users.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,13 @@ describe("users profile audit logs", () => {
20132013
}),
20142014
};
20152015
}
2016+
if (table === "publisherActivityInboxState") {
2017+
return {
2018+
withIndex: () => ({
2019+
unique: vi.fn(async () => null),
2020+
}),
2021+
};
2022+
}
20162023
if (table === "authAccounts" || table === "authSessions") {
20172024
return {
20182025
withIndex: () => ({

convex/users.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
upsertReservedHandleForRightfulOwner,
4040
} from "./lib/reservedHandles";
4141
import { buildUserSearchResults } from "./lib/userSearch";
42+
import { deletePublisherActivityInboxStateForUser } from "./publisherActivityInbox";
4243

4344
const DEFAULT_ROLE = "user";
4445
const ADMIN_HANDLE = "steipete";
@@ -368,6 +369,7 @@ async function hardDeleteSelfDeletedAccountState(
368369
.withIndex("by_user", (q) => q.eq("userId", user._id))
369370
.collect();
370371
for (const membership of githubOrgMemberships) await ctx.db.delete(membership._id);
372+
await deletePublisherActivityInboxStateForUser(ctx, user._id);
371373

372374
const deletedFollows = (await ctx.runMutation(
373375
internal.publisherFollows.deletePublisherFollowsForFollowerInternal,

src/components/Header.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link, useLocation, useNavigate } from "@tanstack/react-router";
33
import { useQuery } from "convex/react";
44
import {
55
ArrowRight,
6+
Bell,
67
ExternalLink,
78
ChevronDown,
89
Command,
@@ -658,6 +659,12 @@ export default function Header() {
658659
Add skill or plugin
659660
</Link>
660661
</DropdownMenuItem>
662+
<DropdownMenuItem asChild>
663+
<Link to="/inbox" className="flex items-center gap-2">
664+
<Bell size={14} aria-hidden="true" />
665+
Inbox
666+
</Link>
667+
</DropdownMenuItem>
661668
<DropdownMenuItem asChild>
662669
<Link to="/stars" className="flex items-center gap-2">
663670
<Star size={14} aria-hidden="true" />

0 commit comments

Comments
 (0)