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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
516 changes: 516 additions & 0 deletions CLEANUP_PLAN.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@

import type * as agentTasks from "../agentTasks.js";
import type * as ai from "../ai.js";
import type * as authUsers from "../authUsers.js";
import type * as discussions from "../discussions.js";
import type * as flashExperiments from "../flashExperiments.js";
import type * as posts from "../posts.js";
import type * as reads from "../reads.js";
import type * as replies from "../replies.js";
import type * as seed from "../seed.js";
import type * as spaces from "../spaces.js";
Expand All @@ -28,10 +28,10 @@ import type {
declare const fullApi: ApiFromModules<{
agentTasks: typeof agentTasks;
ai: typeof ai;
authUsers: typeof authUsers;
discussions: typeof discussions;
flashExperiments: typeof flashExperiments;
posts: typeof posts;
reads: typeof reads;
replies: typeof replies;
seed: typeof seed;
spaces: typeof spaces;
Expand Down
30 changes: 30 additions & 0 deletions convex/authUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Doc } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";

export type AuthIdentity = NonNullable<
Awaited<ReturnType<MutationCtx["auth"]["getUserIdentity"]>>
>;

export async function findUserForIdentity(
ctx: MutationCtx,
identity: AuthIdentity,
): Promise<Doc<"users"> | null> {
const byToken = await ctx.db
.query("users")
.withIndex("by_token_identifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.first();
if (byToken) return byToken;

const bySubject = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.first();
if (!bySubject) return null;

await ctx.db.patch(bySubject._id, {
tokenIdentifier: identity.tokenIdentifier,
});
return { ...bySubject, tokenIdentifier: identity.tokenIdentifier };
}
9 changes: 4 additions & 5 deletions convex/discussions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ConvexError, v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import { findUserForIdentity } from "./authUsers";

/**
* Per-experiment "open discussion" threads.
Expand Down Expand Up @@ -57,10 +58,7 @@ async function getOrCreateViewer(ctx: MutationCtx): Promise<Id<"users">> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) unauthenticated();

const existing = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.first();
const existing = await findUserForIdentity(ctx, identity);
if (existing) return existing._id;

const name =
Expand All @@ -73,9 +71,10 @@ async function getOrCreateViewer(ctx: MutationCtx): Promise<Id<"users">> {
return await ctx.db.insert("users", {
name,
title: "member",
avatarColor: colorFor(identity.subject),
avatarColor: colorFor(identity.tokenIdentifier),
initials: initialsFrom(name),
role: "member",
tokenIdentifier: identity.tokenIdentifier,
subject: identity.subject,
});
}
Expand Down
68 changes: 10 additions & 58 deletions convex/posts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { query, mutation } from "./_generated/server";
import { query } from "./_generated/server";
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
Expand Down Expand Up @@ -48,19 +48,22 @@ export const feed = query({
onlyUnread: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
// Bounded read: the feed only ever renders recent activity, and `enrich`
// costs a postReads lookup per post per viewer — don't scan the table.
const FEED_LIMIT = 200;
let posts: Doc<"posts">[];
if (args.space) {
posts = await ctx.db
.query("posts")
.withIndex("by_space", (q) => q.eq("space", args.space!))
.order("desc")
.collect();
.take(FEED_LIMIT);
} else {
posts = await ctx.db
.query("posts")
.withIndex("by_activity")
.order("desc")
.collect();
.take(FEED_LIMIT);
}

if (args.priority) {
Expand Down Expand Up @@ -120,58 +123,7 @@ export const get = query({
},
});

export const counts = query({
args: { viewerId: v.optional(v.id("users")) },
handler: async (ctx, args) => {
const posts = await ctx.db.query("posts").collect();
if (!args.viewerId) return { total: posts.length, unread: 0, urgent: 0 };
let unread = 0;
let urgent = 0;
for (const post of posts) {
const read = await ctx.db
.query("postReads")
.withIndex("by_user_post", (q) =>
q.eq("userId", args.viewerId!).eq("postId", post._id),
)
.unique();
const isUnread = post.lastActivityAt > (read?.lastReadAt ?? 0);
if (isUnread) {
unread++;
if (post.priority === "urgent") urgent++;
}
}
return { total: posts.length, unread, urgent };
},
});

export const create = mutation({
args: {
authorId: v.id("users"),
title: v.string(),
body: v.string(),
space: v.string(),
priority: priority,
},
handler: async (ctx, args) => {
const now = Date.now();
const postId = await ctx.db.insert("posts", {
authorId: args.authorId,
title: args.title,
body: args.body,
space: args.space,
priority: args.priority,
pinned: false,
createdAt: now,
lastActivityAt: now,
replyCount: 0,
participantIds: [args.authorId],
});
// Author has "read" their own new post.
await ctx.db.insert("postReads", {
userId: args.authorId,
postId,
lastReadAt: now,
});
return postId;
},
});
// NOTE: write paths (create post / reply / mark read) intentionally have no
// public Convex mutations here. Visitor writes live in the client's
// session-only overlay (`src/lib/store.tsx`); the shared demo DB stays
// read-only. The auth-gated exception is `discussions.ts`.
46 changes: 0 additions & 46 deletions convex/reads.ts

This file was deleted.

14 changes: 10 additions & 4 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@ export default defineSchema({
role: v.optional(v.union(v.literal("admin"), v.literal("member"))),
// AI coding agents (Cursor, Codex, Claude Code, …) post as teammates too.
isAgent: v.optional(v.boolean()),
// Set for real, shoo-authenticated members (maps the auth identity's
// `subject` to a `users` doc so they can author posts/replies). Seed
// personas leave this undefined.
// Set for real, shoo-authenticated members (maps the canonical auth token
// identifier to a `users` doc so they can author posts/replies). Seed
// personas leave this undefined. `subject` is retained only as legacy data.
tokenIdentifier: v.optional(v.string()),
subject: v.optional(v.string()),
}).index("by_subject", ["subject"]),
})
.index("by_token_identifier", ["tokenIdentifier"])
.index("by_subject", ["subject"]),

posts: defineTable({
authorId: v.id("users"),
Expand All @@ -66,6 +69,9 @@ export default defineSchema({
// `spaceId` (it scopes cross-org readers); the two travel together.
spaceId: v.optional(v.id("spaces")),
visibility: v.optional(postVisibility),
// Author's org, required for "org"-scoped visibility to mean anything
// (the reader-side filter in `spaces.feedForSpace` compares against it).
orgId: v.optional(v.id("orgs")),
// Group C — per-user walls. null/undefined = normal space post; set = a
// post written on this user's wall.
//
Expand Down
6 changes: 4 additions & 2 deletions convex/seed.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mutation } from "./_generated/server";
import { internalMutation } from "./_generated/server";
import type { Id } from "./_generated/dataModel";

const HOUR = 60 * 60 * 1000;
Expand All @@ -12,7 +12,9 @@ const DAY = 24 * HOUR;
*
* Run with: bunx convex run seed:run
*/
export const run = mutation({
// internalMutation: the seed wipes tables, so it must never be callable from
// the public internet. `convex run seed:run` still reaches it from the CLI.
export const run = internalMutation({
args: {},
handler: async (ctx) => {
// Wipe existing data so the seed is idempotent.
Expand Down
6 changes: 3 additions & 3 deletions convex/spaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ export const feedForSpace = query({
return posts.filter((post) => {
// "org"-scoped posts are only visible to a viewer in the same org.
if (post.visibility !== "org") return true;
if (!args.viewerOrgId) return false;
if (!("orgId" in post)) return false;
return post.orgId === args.viewerOrgId;
return (
args.viewerOrgId !== undefined && post.orgId === args.viewerOrgId
);
});
},
});
32 changes: 21 additions & 11 deletions convex/users.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,43 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError, v } from "convex/values";
import { findUserForIdentity } from "./authUsers";

export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("users").collect();
const users = await ctx.db.query("users").collect();
// The directory is public; the auth-identity mapping is not. Strip auth
// identifiers so visitors can't enumerate real members' identities.
return users.map(
({ tokenIdentifier: _tokenIdentifier, subject: _subject, ...user }) =>
user,
);
},
});

export const setProfileBySubject = mutation({
/** Update the signed-in member's own profile (identity comes from auth, never
* from the client — and `role` is deliberately not editable here). */
export const updateProfile = mutation({
args: {
subject: v.string(),
name: v.string(),
title: v.string(),
role: v.union(v.literal("admin"), v.literal("member")),
initials: v.string(),
},
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", args.subject))
.first();
const identity = await ctx.auth.getUserIdentity();
if (!identity)
throw new ConvexError({
code: "UNAUTHENTICATED",
message: "Sign in to edit your profile.",
});

if (!user) throw new Error("User not found");
const user = await findUserForIdentity(ctx, identity);
if (!user)
throw new ConvexError({ code: "NOT_FOUND", message: "User not found" });

await ctx.db.patch(user._id, {
name: args.name,
title: args.title,
role: args.role,
initials: args.initials,
});
},
Expand Down
6 changes: 4 additions & 2 deletions src/lib/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const summary = summaries[p._id];
const lastActivityAt = bump?.lastActivityAt ?? p.lastActivityAt;
const readAt = effReadAt(p._id, p.lastReadAt);
// Dedupe across backend + session: the replier may already be a backend
// participant (duplicates would double their avatar and collide keys).
const participantIds = bump
? [...p.participantIds, ...bump.addedParticipantIds]
? [...new Set([...p.participantIds, ...bump.addedParticipantIds])]
: p.participantIds;
return {
...p,
Expand All @@ -171,7 +173,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const summary = summaries[sp._id];
const lastActivityAt = bump?.lastActivityAt ?? sp.lastActivityAt;
const participantIds = bump
? [...sp.participantIds, ...bump.addedParticipantIds]
? [...new Set([...sp.participantIds, ...bump.addedParticipantIds])]
: sp.participantIds;
const readAt = effReadAt(sp._id, 0);
return {
Expand Down