Skip to content

Commit 2b0c1bb

Browse files
committed
feat: initial FE+BE wiring
1 parent 5919461 commit 2b0c1bb

53 files changed

Lines changed: 7605 additions & 1444 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ node_modules
22
dist
33
pnpm-lock.yaml
44
convex/_generated
5+
apps/dashboard/test-results
6+
apps/dashboard/playwright-report

apps/dashboard/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11

22
.env.local
3+
4+
# Playwright artifacts
5+
test-results/
6+
playwright-report/

apps/dashboard/convex/_generated/api.d.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@
99
*/
1010

1111
import type * as auth from "../auth.js";
12+
import type * as bookmarks from "../bookmarks.js";
13+
import type * as dev from "../dev.js";
14+
import type * as events from "../events.js";
15+
import type * as follows from "../follows.js";
1216
import type * as http from "../http.js";
17+
import type * as orgs from "../orgs.js";
18+
import type * as rsvps from "../rsvps.js";
19+
import type * as seed from "../seed.js";
20+
import type * as seedData from "../seedData.js";
21+
import type * as users from "../users.js";
1322

1423
import type {
1524
ApiFromModules,
@@ -19,7 +28,16 @@ import type {
1928

2029
declare const fullApi: ApiFromModules<{
2130
auth: typeof auth;
31+
bookmarks: typeof bookmarks;
32+
dev: typeof dev;
33+
events: typeof events;
34+
follows: typeof follows;
2235
http: typeof http;
36+
orgs: typeof orgs;
37+
rsvps: typeof rsvps;
38+
seed: typeof seed;
39+
seedData: typeof seedData;
40+
users: typeof users;
2341
}>;
2442

2543
/**

apps/dashboard/convex/auth.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import Google from "@auth/core/providers/google";
22
import { convexAuth } from "@convex-dev/auth/server";
3+
import { ConvexError } from "convex/values";
4+
5+
const ALLOWED_EMAIL_DOMAIN = "@cornell.edu";
36

47
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
58
providers: [Google],
9+
callbacks: {
10+
async createOrUpdateUser(ctx, args) {
11+
const email = args.profile.email;
12+
if (
13+
typeof email !== "string" ||
14+
!email.toLowerCase().endsWith(ALLOWED_EMAIL_DOMAIN)
15+
) {
16+
throw new ConvexError({
17+
code: "NON_CORNELL_EMAIL",
18+
message: "Loop is open to Cornell students only.",
19+
});
20+
}
21+
if (args.existingUserId) {
22+
return args.existingUserId;
23+
}
24+
const name =
25+
typeof args.profile.name === "string" ? args.profile.name : undefined;
26+
const image =
27+
typeof args.profile.image === "string" ? args.profile.image : undefined;
28+
return await ctx.db.insert("users", {
29+
email: email.toLowerCase(),
30+
name,
31+
image,
32+
});
33+
},
34+
},
635
});

apps/dashboard/convex/bookmarks.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ConvexError, v } from "convex/values";
2+
import { paginationOptsValidator } from "convex/server";
3+
import { getAuthUserId } from "@convex-dev/auth/server";
4+
import type { Doc, Id } from "./_generated/dataModel";
5+
import {
6+
mutation,
7+
query,
8+
type MutationCtx,
9+
type QueryCtx,
10+
} from "./_generated/server";
11+
12+
type HydratedBookmark = {
13+
bookmark: Doc<"bookmarks">;
14+
event: Doc<"events">;
15+
orgs: Doc<"orgs">[];
16+
};
17+
18+
type BookmarkPage = {
19+
page: HydratedBookmark[];
20+
isDone: boolean;
21+
continueCursor: string;
22+
};
23+
24+
async function loadOrgsForEvent(
25+
ctx: QueryCtx,
26+
eventId: Id<"events">,
27+
): Promise<Doc<"orgs">[]> {
28+
const joins = await ctx.db
29+
.query("eventOrgs")
30+
.withIndex("by_event", (q) => q.eq("eventId", eventId))
31+
.take(8);
32+
33+
const orgs: Doc<"orgs">[] = [];
34+
for (const join of joins) {
35+
const org = await ctx.db.get(join.orgId);
36+
if (org !== null) {
37+
orgs.push(org);
38+
}
39+
}
40+
return orgs;
41+
}
42+
43+
async function findBookmark(
44+
ctx: QueryCtx | MutationCtx,
45+
userId: Id<"users">,
46+
eventId: Id<"events">,
47+
): Promise<Doc<"bookmarks"> | null> {
48+
return await ctx.db
49+
.query("bookmarks")
50+
.withIndex("by_user_and_event", (q) =>
51+
q.eq("userId", userId).eq("eventId", eventId),
52+
)
53+
.unique();
54+
}
55+
56+
export const bookmark = mutation({
57+
args: { eventId: v.id("events") },
58+
handler: async (ctx, args) => {
59+
const userId = await getAuthUserId(ctx);
60+
if (userId === null) {
61+
throw new ConvexError({
62+
code: "UNAUTHENTICATED",
63+
message: "You must be signed in to bookmark an event.",
64+
});
65+
}
66+
67+
const existing = await findBookmark(ctx, userId, args.eventId);
68+
if (existing !== null) {
69+
// Idempotent: already bookmarked.
70+
return null;
71+
}
72+
73+
await ctx.db.insert("bookmarks", {
74+
userId,
75+
eventId: args.eventId,
76+
createdAt: Date.now(),
77+
});
78+
return null;
79+
},
80+
});
81+
82+
export const unbookmark = mutation({
83+
args: { eventId: v.id("events") },
84+
handler: async (ctx, args) => {
85+
const userId = await getAuthUserId(ctx);
86+
if (userId === null) {
87+
throw new ConvexError({
88+
code: "UNAUTHENTICATED",
89+
message: "You must be signed in to remove a bookmark.",
90+
});
91+
}
92+
93+
const existing = await findBookmark(ctx, userId, args.eventId);
94+
if (existing !== null) {
95+
await ctx.db.delete(existing._id);
96+
}
97+
return null;
98+
},
99+
});
100+
101+
export const isBookmarked = query({
102+
args: { eventId: v.id("events") },
103+
handler: async (ctx, args): Promise<boolean> => {
104+
const userId = await getAuthUserId(ctx);
105+
if (userId === null) {
106+
return false;
107+
}
108+
const existing = await findBookmark(ctx, userId, args.eventId);
109+
return existing !== null;
110+
},
111+
});
112+
113+
export const myBookmarks = query({
114+
args: { paginationOpts: paginationOptsValidator },
115+
handler: async (ctx, args): Promise<BookmarkPage> => {
116+
const userId = await getAuthUserId(ctx);
117+
if (userId === null) {
118+
return {
119+
page: [],
120+
isDone: true,
121+
continueCursor: "",
122+
};
123+
}
124+
125+
const result = await ctx.db
126+
.query("bookmarks")
127+
.withIndex("by_user", (q) => q.eq("userId", userId))
128+
.order("desc")
129+
.paginate(args.paginationOpts);
130+
131+
const hydrated: HydratedBookmark[] = [];
132+
for (const row of result.page) {
133+
const event = await ctx.db.get(row.eventId);
134+
if (event === null) continue;
135+
const orgs = await loadOrgsForEvent(ctx, event._id);
136+
hydrated.push({ bookmark: row, event, orgs });
137+
}
138+
139+
return {
140+
page: hydrated,
141+
isDone: result.isDone,
142+
continueCursor: result.continueCursor,
143+
};
144+
},
145+
});

0 commit comments

Comments
 (0)