diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d046df1..86cf08e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -176,7 +176,7 @@ notifyDMMessage(thread, sender) // β†’ the other thread participant ```typescript export async function runMaintenanceJobs() // Invoked by GET /cron/maintenance from Vercel Cron every five minutes. -// Runs pod expiry, one-hour meetup reminders, recap prompts, and stale +// Runs pod expiry, one-hour meetup reminders, and stale // waitlist promotion outside the request-serving process. ``` diff --git a/backend/.env.example b/backend/.env.example index c293235..8972b99 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -55,13 +55,17 @@ OPENAI_MODERATION_MODEL=omni-moderation-latest # "required" fails closed if moderation is unavailable; production defaults to required. MODERATION_ENFORCEMENT=required +# Public base URL of this API, used in emailed links (web account deletion). +# Defaults to https://api.theovalapp.com when unset. +# API_PUBLIC_URL=https://api.theovalapp.com + # Universal/App Links IOS_APP_ID=com.bradyvb.ovalapp APPLE_TEAM_ID=687FPU46UV -ANDROID_PACKAGE=com.bradyvb.ovalapp +ANDROID_PACKAGE=com.theovalapp.app # Add after the corresponding store listing exists. Until then, invite pages show # a coming-soon message instead of a dead store link. # IOS_APP_STORE_URL=https://apps.apple.com/us/app/oval/id1234567890 -# ANDROID_STORE_URL=https://play.google.com/store/apps/details?id=com.bradyvb.ovalapp +# ANDROID_STORE_URL=https://play.google.com/store/apps/details?id=com.theovalapp.app # Add after the Android production keystore exists. # ANDROID_SHA256_CERT_FINGERPRINT=AA:BB:CC:... diff --git a/backend/package-lock.json b/backend/package-lock.json index e6edfb3..0147cea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -58,22 +58,34 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -1137,9 +1149,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", diff --git a/backend/prisma/migrations/20260729010000_club_cover_and_ownership_audit/migration.sql b/backend/prisma/migrations/20260729010000_club_cover_and_ownership_audit/migration.sql new file mode 100644 index 0000000..4be07b6 --- /dev/null +++ b/backend/prisma/migrations/20260729010000_club_cover_and_ownership_audit/migration.sql @@ -0,0 +1,41 @@ +-- Add an independently managed club cover image. +ALTER TABLE "Club" ADD COLUMN "coverUrl" TEXT; +ALTER TABLE "ClubMember" ADD COLUMN "permissions" TEXT; + +-- Keep a durable audit trail for high-impact ownership handoffs. +CREATE TABLE "ClubOwnershipTransfer" ( + "id" TEXT NOT NULL, + "clubId" TEXT NOT NULL, + "fromUserId" TEXT, + "toUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ClubOwnershipTransfer_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ClubOwnershipTransfer_clubId_createdAt_idx" +ON "ClubOwnershipTransfer"("clubId", "createdAt"); + +CREATE INDEX "ClubOwnershipTransfer_fromUserId_idx" +ON "ClubOwnershipTransfer"("fromUserId"); + +CREATE INDEX "ClubOwnershipTransfer_toUserId_idx" +ON "ClubOwnershipTransfer"("toUserId"); + +ALTER TABLE "ClubOwnershipTransfer" +ADD CONSTRAINT "ClubOwnershipTransfer_clubId_fkey" +FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ClubOwnershipTransfer" +ADD CONSTRAINT "ClubOwnershipTransfer_fromUserId_fkey" +FOREIGN KEY ("fromUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "ClubOwnershipTransfer" +ADD CONSTRAINT "ClubOwnershipTransfer_toUserId_fkey" +FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Club membership roles are the runtime authorization source. Enforce the +-- invariant in PostgreSQL so concurrent requests cannot create co-owners. +CREATE UNIQUE INDEX "ClubMember_single_owner_per_club_idx" +ON "ClubMember"("clubId") +WHERE "role" = 'OWNER'; diff --git a/backend/prisma/migrations/20260729020000_club_role_ordering/migration.sql b/backend/prisma/migrations/20260729020000_club_role_ordering/migration.sql new file mode 100644 index 0000000..855e8d3 --- /dev/null +++ b/backend/prisma/migrations/20260729020000_club_role_ordering/migration.sql @@ -0,0 +1,17 @@ +ALTER TABLE "ClubRole" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0; + +WITH ranked AS ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "clubId" + ORDER BY "createdAt" ASC, "name" ASC + ) - 1 AS next_position + FROM "ClubRole" +) +UPDATE "ClubRole" +SET "position" = ranked.next_position +FROM ranked +WHERE "ClubRole"."id" = ranked."id"; + +CREATE INDEX "ClubRole_clubId_position_idx" ON "ClubRole"("clubId", "position"); diff --git a/backend/prisma/migrations/20260729030000_club_announcement_meeting/migration.sql b/backend/prisma/migrations/20260729030000_club_announcement_meeting/migration.sql new file mode 100644 index 0000000..23a682a --- /dev/null +++ b/backend/prisma/migrations/20260729030000_club_announcement_meeting/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE "ClubAnnouncement" +ADD COLUMN "meetingId" TEXT, +ADD COLUMN "notifyMembers" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "ClubAnnouncement" +ADD CONSTRAINT "ClubAnnouncement_meetingId_fkey" +FOREIGN KEY ("meetingId") REFERENCES "ClubMeeting"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "ClubAnnouncement_meetingId_idx" ON "ClubAnnouncement"("meetingId"); diff --git a/backend/prisma/migrations/20260729040000_club_message_replies_reactions/migration.sql b/backend/prisma/migrations/20260729040000_club_message_replies_reactions/migration.sql new file mode 100644 index 0000000..c3322d2 --- /dev/null +++ b/backend/prisma/migrations/20260729040000_club_message_replies_reactions/migration.sql @@ -0,0 +1,30 @@ +ALTER TABLE "ClubMessage" ADD COLUMN "replyToId" TEXT; + +ALTER TABLE "ClubMessage" +ADD CONSTRAINT "ClubMessage_replyToId_fkey" +FOREIGN KEY ("replyToId") REFERENCES "ClubMessage"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE TABLE "ClubMessageReaction" ( + "id" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "emoji" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ClubMessageReaction_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "ClubMessageReaction" +ADD CONSTRAINT "ClubMessageReaction_messageId_fkey" +FOREIGN KEY ("messageId") REFERENCES "ClubMessage"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ClubMessageReaction" +ADD CONSTRAINT "ClubMessageReaction_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "ClubMessageReaction_messageId_userId_emoji_key" +ON "ClubMessageReaction"("messageId", "userId", "emoji"); +CREATE INDEX "ClubMessageReaction_messageId_idx" ON "ClubMessageReaction"("messageId"); +CREATE INDEX "ClubMessageReaction_userId_idx" ON "ClubMessageReaction"("userId"); diff --git a/backend/prisma/migrations/20260729050000_club_message_images/migration.sql b/backend/prisma/migrations/20260729050000_club_message_images/migration.sql new file mode 100644 index 0000000..1ed47df --- /dev/null +++ b/backend/prisma/migrations/20260729050000_club_message_images/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ClubMessage" +ADD COLUMN "imageUrl" TEXT; diff --git a/backend/prisma/migrations/20260730010000_pod_plan_fields/migration.sql b/backend/prisma/migrations/20260730010000_pod_plan_fields/migration.sql new file mode 100644 index 0000000..9527131 --- /dev/null +++ b/backend/prisma/migrations/20260730010000_pod_plan_fields/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "Pod" +ADD COLUMN "title" TEXT, +ADD COLUMN "note" TEXT, +ADD COLUMN "locationAddress" TEXT; + +ALTER TABLE "Club" +ADD COLUMN "discoveryPreference" TEXT NOT NULL DEFAULT 'CAMPUS'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 74590f0..852b456 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -59,29 +59,32 @@ model User { waitlistEntries PodWaitlist[] podReadStates PodReadState[] - sentFriendRequests FriendRequest[] @relation("FriendRequestSender") - receivedFriendRequests FriendRequest[] @relation("FriendRequestReceiver") - friendshipsAsA Friendship[] @relation("FriendshipUserA") - friendshipsAsB Friendship[] @relation("FriendshipUserB") - dmThreadsAsA DirectMessageThread[] @relation("DMThreadUserA") - dmThreadsAsB DirectMessageThread[] @relation("DMThreadUserB") - directMessages DirectMessage[] - sentPodInvites PodInvite[] @relation("PodInviteSender") - receivedPodInvites PodInvite[] @relation("PodInviteReceiver") - clubsCreated Club[] @relation("ClubCreator") - clubMemberships ClubMember[] - clubRolesCreated ClubRole[] @relation("ClubRoleCreator") - clubMeetingsCreated ClubMeeting[] @relation("MeetingCreator") - clubMeetingAttendees ClubMeetingAttendee[] - clubAnnouncements ClubAnnouncement[] - clubMessages ClubMessage[] - clubOfficerMessages ClubOfficerMessage[] - clubChannelReadStates ClubChannelReadState[] - clubFollows ClubFollower[] - clubClaims ClubClaim[] - clubApplications ClubApplication[] - activityRequests ActivityRequest[] - analyticsEvents AnalyticsEvent[] + sentFriendRequests FriendRequest[] @relation("FriendRequestSender") + receivedFriendRequests FriendRequest[] @relation("FriendRequestReceiver") + friendshipsAsA Friendship[] @relation("FriendshipUserA") + friendshipsAsB Friendship[] @relation("FriendshipUserB") + dmThreadsAsA DirectMessageThread[] @relation("DMThreadUserA") + dmThreadsAsB DirectMessageThread[] @relation("DMThreadUserB") + directMessages DirectMessage[] + sentPodInvites PodInvite[] @relation("PodInviteSender") + receivedPodInvites PodInvite[] @relation("PodInviteReceiver") + clubsCreated Club[] @relation("ClubCreator") + clubMemberships ClubMember[] + clubRolesCreated ClubRole[] @relation("ClubRoleCreator") + clubMeetingsCreated ClubMeeting[] @relation("MeetingCreator") + clubMeetingAttendees ClubMeetingAttendee[] + clubAnnouncements ClubAnnouncement[] + clubMessages ClubMessage[] + clubMessageReactions ClubMessageReaction[] + clubOfficerMessages ClubOfficerMessage[] + clubChannelReadStates ClubChannelReadState[] + clubFollows ClubFollower[] + clubClaims ClubClaim[] + clubApplications ClubApplication[] + clubOwnershipTransfersFrom ClubOwnershipTransfer[] @relation("ClubOwnershipTransferFrom") + clubOwnershipTransfersTo ClubOwnershipTransfer[] @relation("ClubOwnershipTransferTo") + activityRequests ActivityRequest[] + analyticsEvents AnalyticsEvent[] } model Block { @@ -106,7 +109,7 @@ model Activity { defaultLocation String createdAt DateTime @default(now()) - pods Pod[] + pods Pod[] demands PodDemand[] } @@ -133,8 +136,11 @@ model ActivityRequest { model Pod { id String @id @default(uuid()) activityId String + title String? + note String? meetupTime DateTime location String + locationAddress String? locationType String @default("public") // "public" | "private" minMembers Int @default(2) maxMembers Int @default(4) @@ -162,11 +168,11 @@ model Pod { } model PodMember { - id String @id @default(uuid()) - podId String - userId String - joinedAt DateTime @default(now()) - confirmedAt DateTime? + id String @id @default(uuid()) + podId String + userId String + joinedAt DateTime @default(now()) + confirmedAt DateTime? firstPodNudgeSentAt DateTime? pod Pod @relation(fields: [podId], references: [id], onDelete: Cascade) @@ -487,8 +493,9 @@ model Club { category String emoji String @default("πŸŽ“") avatarUrl String? + coverUrl String? isVerified Boolean @default(false) // legacy, dropped in Phase 4 (see CLUB_LIFECYCLE_IMPLEMENTATION.md) - isPublic Boolean @default(true) // legacy, dropped in Phase 4 + isPublic Boolean @default(true) // legacy, dropped in Phase 4 university String @default("OSU") createdById String officerPermissions String @default("[]") @@ -496,10 +503,11 @@ model Club { updatedAt DateTime @updatedAt // --- Club lifecycle: four orthogonal axes --- - isDiscoverable Boolean @default(false) - verification String @default("UNVERIFIED") // UNVERIFIED | PENDING_REVIEW | VERIFIED - joinPolicy String @default("OPEN") // OPEN | REQUEST | APPLICATION | INVITE_ONLY - status String @default("ACTIVE") // ACTIVE | SUSPENDED | ARCHIVED + isDiscoverable Boolean @default(false) + discoveryPreference String @default("CAMPUS") // CAMPUS | INVITE_ONLY + verification String @default("UNVERIFIED") // UNVERIFIED | PENDING_REVIEW | VERIFIED + joinPolicy String @default("OPEN") // OPEN | REQUEST | APPLICATION | INVITE_ONLY + status String @default("ACTIVE") // ACTIVE | SUSPENDED | ARCHIVED // verification metadata verificationMethod String? // INSTAGRAM | OFFICIAL_EMAIL | DIRECTORY | MANUAL @@ -510,28 +518,47 @@ model Club { lastReaffirmedAt DateTime? // bookkeeping - discoverableSince DateTime? - suspendedAt DateTime? - suspendedReason String? - - createdBy User @relation("ClubCreator", fields: [createdById], references: [id], onDelete: Restrict) - members ClubMember[] - roles ClubRole[] - meetings ClubMeeting[] - announcements ClubAnnouncement[] - messages ClubMessage[] - officerMessages ClubOfficerMessage[] - channels ClubChannel[] - followers ClubFollower[] - claims ClubClaim[] - invites ClubInvite[] - appCycles ClubApplicationCycle[] + discoverableSince DateTime? + suspendedAt DateTime? + suspendedReason String? + + createdBy User @relation("ClubCreator", fields: [createdById], references: [id], onDelete: Restrict) + members ClubMember[] + roles ClubRole[] + meetings ClubMeeting[] + announcements ClubAnnouncement[] + messages ClubMessage[] + officerMessages ClubOfficerMessage[] + channels ClubChannel[] + followers ClubFollower[] + claims ClubClaim[] + invites ClubInvite[] + appCycles ClubApplicationCycle[] + ownershipTransfers ClubOwnershipTransfer[] @@index([isPublic, category]) @@index([isDiscoverable, status, category]) @@index([university, isDiscoverable]) } +/// Immutable audit trail for the high-impact owner handoff operation. +/// User references become null after account deletion while the transfer record remains. +model ClubOwnershipTransfer { + id String @id @default(uuid()) + clubId String + fromUserId String? + toUserId String? + createdAt DateTime @default(now()) + + club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) + fromUser User? @relation("ClubOwnershipTransferFrom", fields: [fromUserId], references: [id], onDelete: SetNull) + toUser User? @relation("ClubOwnershipTransferTo", fields: [toUserId], references: [id], onDelete: SetNull) + + @@index([clubId, createdAt]) + @@index([fromUserId]) + @@index([toUserId]) +} + model ClubChannel { id String @id @default(uuid()) clubId String @@ -569,11 +596,13 @@ model ClubChannelReadState { } model ClubMember { - id String @id @default(uuid()) - clubId String - userId String - role String @default("MEMBER") - joinedAt DateTime @default(now()) + id String @id @default(uuid()) + clubId String + userId String + role String @default("MEMBER") + /// Optional JSON permission override for this individual officer. + permissions String? + joinedAt DateTime @default(now()) club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -592,6 +621,7 @@ model ClubRole { /// Named accent from the app palette (e.g. "blue", "violet"); null = neutral color String? isSelfAssignable Boolean @default(false) + position Int @default(0) createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -602,6 +632,7 @@ model ClubRole { @@unique([clubId, name]) @@index([clubId]) + @@index([clubId, position]) } model ClubMemberRole { @@ -641,9 +672,10 @@ model ClubMeeting { rsvpReminderCount Int @default(0) rsvpReminderError String? - club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) - createdBy User @relation("MeetingCreator", fields: [createdById], references: [id]) - attendees ClubMeetingAttendee[] + club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) + createdBy User @relation("MeetingCreator", fields: [createdById], references: [id]) + attendees ClubMeetingAttendee[] + announcements ClubAnnouncement[] @@index([clubId, meetingTime]) @@index([meetingTime]) @@ -672,12 +704,16 @@ model ClubAnnouncement { content String visibility String @default("PUBLIC") targetRoleIds String? + meetingId String? + notifyMembers Boolean @default(true) createdAt DateTime @default(now()) - club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + meeting ClubMeeting? @relation(fields: [meetingId], references: [id], onDelete: SetNull) @@index([clubId, createdAt]) + @@index([meetingId]) } model ClubMessage { @@ -687,19 +723,40 @@ model ClubMessage { channelId String? userId String content String + /// Moderated club-chat image stored in managed club media storage. + imageUrl String? /// JSON array of ClubRole ids pinged in this message mentionRoleIds String? + replyToId String? createdAt DateTime @default(now()) - club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) - channel ClubChannel? @relation(fields: [channelId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + club Club @relation(fields: [clubId], references: [id], onDelete: Cascade) + channel ClubChannel? @relation(fields: [channelId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + replyTo ClubMessage? @relation("ClubMessageReplies", fields: [replyToId], references: [id], onDelete: SetNull) + replies ClubMessage[] @relation("ClubMessageReplies") + reactions ClubMessageReaction[] @@index([clubId, createdAt]) @@index([channelId, createdAt]) @@index([userId]) } +model ClubMessageReaction { + id String @id @default(uuid()) + messageId String + userId String + emoji String + createdAt DateTime @default(now()) + + message ClubMessage @relation(fields: [messageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([messageId, userId, emoji]) + @@index([messageId]) + @@index([userId]) +} + model ClubOfficerMessage { id String @id @default(uuid()) clubId String diff --git a/backend/src/lib/accountDeletion.ts b/backend/src/lib/accountDeletion.ts new file mode 100644 index 0000000..944f93c --- /dev/null +++ b/backend/src/lib/accountDeletion.ts @@ -0,0 +1,157 @@ +import prisma from '../prisma'; +import { cleanupAvatarUrl } from './avatarStorage'; + +/** + * Full account deletion, shared by the in-app path (DELETE /users/me) and the + * public web deletion flow required by Google Play. Handles club ownership + * succession, report anonymization, message cleanup, and avatar storage + * cleanup. Returns false if the user no longer exists. + */ + +function deletionSuccessorRank(role: string): number { + if (role === 'OWNER') return 4; + if (role === 'ADMIN') return 3; + if (role === 'OFFICER') return 2; + return 1; +} + +export async function deleteUserAccount(userId: string): Promise { + const existing = await prisma.user.findUnique({ + where: { id: userId }, + select: { avatarUrl: true }, + }); + if (!existing) return false; + + await prisma.$transaction(async (tx) => { + const ownedClubs = await tx.club.findMany({ + where: { createdById: userId }, + include: { + members: { + where: { userId: { not: userId } }, + select: { userId: true, role: true, joinedAt: true }, + }, + }, + }); + + for (const club of ownedClubs) { + const successor = [...club.members].sort((a, b) => { + const rankDifference = deletionSuccessorRank(b.role) - deletionSuccessorRank(a.role); + return rankDifference || a.joinedAt.getTime() - b.joinedAt.getTime(); + })[0]; + + if (!successor) { + await tx.club.delete({ where: { id: club.id } }); + continue; + } + + await tx.club.update({ + where: { id: club.id }, + data: { createdById: successor.userId }, + }); + // Repair any legacy creator/role mismatch before appointing the + // successor. The partial unique index permits exactly one OWNER. + await tx.clubMember.updateMany({ + where: { clubId: club.id, role: 'OWNER', userId: { not: successor.userId } }, + data: { role: 'ADMIN' }, + }); + await tx.clubMember.updateMany({ + where: { clubId: club.id, userId: successor.userId }, + data: { role: 'OWNER' }, + }); + await tx.clubRole.updateMany({ + where: { clubId: club.id, createdById: userId }, + data: { createdById: successor.userId }, + }); + await tx.clubMeeting.updateMany({ + where: { clubId: club.id, createdById: userId }, + data: { createdById: successor.userId }, + }); + await tx.clubMemberRole.updateMany({ + where: { clubId: club.id, assignedById: userId }, + data: { assignedById: successor.userId }, + }); + } + + const clubsWithRemainingAuthorship = await tx.club.findMany({ + where: { + OR: [ + { roles: { some: { createdById: userId } } }, + { meetings: { some: { createdById: userId } } }, + ], + }, + select: { id: true, createdById: true }, + }); + + for (const club of clubsWithRemainingAuthorship) { + await tx.clubRole.updateMany({ + where: { clubId: club.id, createdById: userId }, + data: { createdById: club.createdById }, + }); + await tx.clubMeeting.updateMany({ + where: { clubId: club.id, createdById: userId }, + data: { createdById: club.createdById }, + }); + } + + const assignmentClubIds = Array.from(new Set( + (await tx.clubMemberRole.findMany({ + where: { assignedById: userId }, + select: { clubId: true }, + })).map((assignment) => assignment.clubId) + )); + for (const clubId of assignmentClubIds) { + const club = await tx.club.findUnique({ where: { id: clubId }, select: { createdById: true } }); + if (club) { + await tx.clubMemberRole.updateMany({ + where: { clubId, assignedById: userId }, + data: { assignedById: club.createdById }, + }); + } + } + + const [podMessageIds, directMessageIds, clubMessageIds, officerMessageIds, announcementIds] = + await Promise.all([ + tx.message.findMany({ where: { userId }, select: { id: true } }), + tx.directMessage.findMany({ where: { senderId: userId }, select: { id: true } }), + tx.clubMessage.findMany({ where: { userId }, select: { id: true } }), + tx.clubOfficerMessage.findMany({ where: { userId }, select: { id: true } }), + tx.clubAnnouncement.findMany({ where: { userId }, select: { id: true } }), + ]); + + await Promise.all([ + tx.report.updateMany({ + where: { messageId: { in: podMessageIds.map((item) => item.id) } }, + data: { messageId: null }, + }), + tx.report.updateMany({ + where: { directMessageId: { in: directMessageIds.map((item) => item.id) } }, + data: { directMessageId: null }, + }), + tx.report.updateMany({ + where: { clubMessageId: { in: clubMessageIds.map((item) => item.id) } }, + data: { clubMessageId: null }, + }), + tx.report.updateMany({ + where: { clubOfficerMessageId: { in: officerMessageIds.map((item) => item.id) } }, + data: { clubOfficerMessageId: null }, + }), + tx.report.updateMany({ + where: { clubAnnouncementId: { in: announcementIds.map((item) => item.id) } }, + data: { clubAnnouncementId: null }, + }), + ]); + await tx.noShowReport.deleteMany({ + where: { OR: [{ reporterId: userId }, { targetUserId: userId }] }, + }); + + await tx.message.deleteMany({ where: { userId } }); + await tx.directMessage.deleteMany({ where: { senderId: userId } }); + await tx.clubAnnouncement.deleteMany({ where: { userId } }); + await tx.clubMessage.deleteMany({ where: { userId } }); + await tx.clubOfficerMessage.deleteMany({ where: { userId } }); + await tx.user.delete({ where: { id: userId } }); + }); + + await cleanupAvatarUrl(existing.avatarUrl); + return true; +} diff --git a/backend/src/lib/avatarStorage.ts b/backend/src/lib/avatarStorage.ts new file mode 100644 index 0000000..fc2492c --- /dev/null +++ b/backend/src/lib/avatarStorage.ts @@ -0,0 +1,47 @@ +import path from 'path'; +import fs from 'fs'; +import { isSupabaseStorageConfigured, supabaseStorage } from './supabaseStorage'; + +export const USER_AVATAR_BUCKET = 'user-avatars'; + +export const UPLOAD_DIR = path.join(__dirname, '../../uploads/avatars'); +// Resolved once at startup; used to guard against path traversal when deleting old avatars. +const UPLOAD_DIR_RESOLVED = path.resolve(UPLOAD_DIR); +try { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +} catch { + // Vercel read-only filesystem β€” ignore +} + +/** + * Safely unlinks an avatar file. Resolves the stored path and verifies it + * lives inside UPLOAD_DIR before deleting β€” prevents path traversal if the + * DB record were ever tampered with. + */ +function safeUnlinkAvatar(storedUrl: string): void { + const fullPath = path.resolve(path.join(__dirname, '../../', storedUrl)); + if (fullPath.startsWith(UPLOAD_DIR_RESOLVED + path.sep)) { + fs.unlink(fullPath, () => {}); // best-effort, ignore ENOENT + } +} + +function supabaseObjectNameFromPublicUrl(url: string, bucket: string): string | null { + const marker = `/storage/v1/object/public/${bucket}/`; + const index = url.indexOf(marker); + if (index === -1) return null; + return decodeURIComponent(url.slice(index + marker.length)); +} + +export async function cleanupAvatarUrl(avatarUrl: string | null | undefined): Promise { + if (!avatarUrl) return; + + const objectName = isSupabaseStorageConfigured() + ? supabaseObjectNameFromPublicUrl(avatarUrl, USER_AVATAR_BUCKET) + : null; + if (objectName) { + await supabaseStorage.storage.from(USER_AVATAR_BUCKET).remove([objectName]); + return; + } + + safeUnlinkAvatar(avatarUrl); +} diff --git a/backend/src/lib/emailService.ts b/backend/src/lib/emailService.ts index 7a9fb03..ec17b5f 100644 --- a/backend/src/lib/emailService.ts +++ b/backend/src/lib/emailService.ts @@ -107,6 +107,52 @@ export async function sendPasswordResetEmail(to: string, code: string): Promise< console.log(`[emailService] Password reset email sent successfully to ${to} (id: ${result.data?.id}) at ${new Date().toISOString()}`); } +export async function sendAccountDeletionEmail(to: string, confirmUrl: string): Promise { + if (process.env.NODE_ENV !== 'production') { + console.log(`[Oval] Account deletion link for ${to}: ${confirmUrl}`); + } + + if (process.env.NODE_ENV === 'test') { + console.log('[emailService] Test environment detected β€” deletion email not sent. Use link above.'); + return; + } + + const resend = getResendClient(); + if (!resend) { + console.log('[emailService] No RESEND_API_KEY set β€” deletion email not sent. Use link above.'); + return; + } + + const from = getFromEmail(); + const result = await resend.emails.send({ + from, + to, + subject: 'Confirm your Oval account deletion', + html: ` +
+

Delete your Oval account

+

We received a request to permanently delete the Oval account for ${escapeHtml(to)}.

+

If this was you, confirm below. This permanently removes your profile, messages, + pods, club memberships, and friend connections. This cannot be undone.

+
+ + Confirm deletion + +
+

This link expires in 1 hour. If you did not request + deletion, ignore this email β€” your account is safe and nothing will happen.

+
+ `, + }); + + if (result.error) { + throw new Error(`Resend API error for ${to}: ${result.error.message}`); + } + + console.log(`[emailService] Deletion email sent successfully to ${to} (id: ${result.data?.id}) at ${new Date().toISOString()}`); +} + export interface ModerationReportEmailPayload { id: string; severity: string; diff --git a/backend/src/lib/maintenanceJobs.ts b/backend/src/lib/maintenanceJobs.ts index be4a95c..4298a8a 100644 --- a/backend/src/lib/maintenanceJobs.ts +++ b/backend/src/lib/maintenanceJobs.ts @@ -10,7 +10,6 @@ export async function runMaintenanceJobs() { pushReceipts, , , - , firstPodNudges, demandPrompts, weeklyRecaps, @@ -19,7 +18,6 @@ export async function runMaintenanceJobs() { ] = await Promise.all([ NotificationService.checkPushReceipts(), NotificationService.sendMeetupReminders(), - NotificationService.sendRecapPrompts(), NotificationService.expireStaleWaitlistEntries(), NotificationService.sendFirstPodNudges(), NotificationService.sendDemandConversionPrompts(), diff --git a/backend/src/routes/activities.test.ts b/backend/src/routes/activities.test.ts index f35d58b..75738fd 100644 --- a/backend/src/routes/activities.test.ts +++ b/backend/src/routes/activities.test.ts @@ -227,6 +227,16 @@ describe('activity requests', () => { expect(approved.body.request.status).toBe('APPROVED'); expect(approved.body.activity.title).toBe(title); + const reviewed = await request(app) + .get('/admin/activity-requests?status=reviewed') + .set('Authorization', `Bearer ${admin.token}`) + .expect(200); + expect(reviewed.body.requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: submitted.body.id, status: 'APPROVED' }), + ]), + ); + const duplicate = await request(app) .post('/activities/requests') .set('Authorization', `Bearer ${student.token}`) @@ -269,6 +279,20 @@ describe('activity requests', () => { expect(rejected.body.request.status).toBe('REJECTED'); expect(rejected.body.request.reviewNote).toBe('Too similar to the current catalog.'); + + const reviewed = await request(app) + .get('/admin/activity-requests?status=reviewed') + .set('Authorization', `Bearer ${admin.token}`) + .expect(200); + expect(reviewed.body.requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: submitted.body.id, + status: 'REJECTED', + reviewNote: 'Too similar to the current catalog.', + }), + ]), + ); } finally { if (previousAdmins === undefined) delete process.env.ADMIN_USER_IDS; else process.env.ADMIN_USER_IDS = previousAdmins; diff --git a/backend/src/routes/adminActivityRequests.ts b/backend/src/routes/adminActivityRequests.ts index c63a37b..fa34f55 100644 --- a/backend/src/routes/adminActivityRequests.ts +++ b/backend/src/routes/adminActivityRequests.ts @@ -35,13 +35,16 @@ function serializeRequest(row: { }; } -// GET /admin/activity-requests β€” pending student activity suggestions. -router.get('/', async (_req: AuthRequest, res: Response): Promise => { +// GET /admin/activity-requests β€” pending suggestions by default, or the +// persisted approval/rejection history with ?status=reviewed. +router.get('/', async (req: AuthRequest, res: Response): Promise => { + const reviewed = req.query.status === 'reviewed'; try { const requests = await prisma.activityRequest.findMany({ - where: { status: 'PENDING' }, - orderBy: { createdAt: 'asc' }, + where: reviewed ? { status: { in: ['APPROVED', 'REJECTED'] } } : { status: 'PENDING' }, + orderBy: { createdAt: reviewed ? 'desc' : 'asc' }, include: { user: { select: { id: true, name: true } } }, + take: reviewed ? 100 : undefined, }); res.json({ requests: requests.map(serializeRequest) }); } catch (err) { @@ -126,4 +129,3 @@ router.post('/:id/reject', async (req: AuthRequest, res: Response): Promise { const res = await request(app) .post('/clubs') .set('Authorization', `Bearer ${token}`) - .send(validCreateBody()) + .send({ ...validCreateBody(), discoveryPreference: 'INVITE_ONLY' }) .expect(201); await prisma.club.update({ where: { id: res.body.id }, data: { isDiscoverable: true } }); @@ -48,6 +48,8 @@ describe('Clubs API (integration)', () => { where: { clubId_userId: { clubId: res.body.id, userId } }, }); expect(row?.role).toBe('OWNER'); + const club = await prisma.club.findUnique({ where: { id: res.body.id } }); + expect(club?.discoveryPreference).toBe('INVITE_ONLY'); }); it('returns 400 when required fields are missing', async () => { @@ -365,6 +367,12 @@ describe('Clubs API (integration)', () => { .expect(200); expect(applyInfo.body.openCycle.id).toBe(cycle.body.id); expect(applyInfo.body.isMember).toBe(false); + expect(applyInfo.body.club).toMatchObject({ + id: clubId, + name: create.body.name, + category: create.body.category, + memberCount: 1, + }); await request(app) .post(`/clubs/${clubId}/application-cycles/${cycle.body.id}/apply`) @@ -438,6 +446,58 @@ describe('Clubs API (integration)', () => { .send({ answers: ['x'] }) .expect(409); }); + + it('keeps application status visible after a cycle closes and lets the applicant withdraw', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + const cycle = await request(app) + .post(`/clubs/${create.body.id}/application-cycles`) + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Fall 2026', + questions: ['Why join?', 'What do you hope to learn?'], + closesAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + }) + .expect(201); + const applicant = await registerAndGetToken( + 'Status Applicant', + `status-applicant-${Date.now()}@example.com`, + 'password123' + ); + const applied = await request(app) + .post(`/clubs/${create.body.id}/application-cycles/${cycle.body.id}/apply`) + .set('Authorization', `Bearer ${applicant.token}`) + .send({ answers: ['Community', 'Portrait lighting'] }) + .expect(201); + await request(app) + .patch(`/clubs/${create.body.id}/application-cycles/${cycle.body.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ status: 'CLOSED' }) + .expect(200); + + const info = await request(app) + .get(`/clubs/${create.body.id}/apply`) + .set('Authorization', `Bearer ${applicant.token}`) + .expect(200); + expect(info.body.openCycle).toBeNull(); + expect(info.body.myApplication).toMatchObject({ + id: applied.body.id, + stage: 'APPLIED', + answerCount: 2, + cycle: { id: cycle.body.id, title: 'Fall 2026' }, + }); + + await request(app) + .patch(`/clubs/${create.body.id}/applications/${applied.body.id}/withdraw`) + .set('Authorization', `Bearer ${applicant.token}`) + .expect(200); + expect( + (await prisma.clubApplication.findUnique({ where: { id: applied.body.id } }))?.stage + ).toBe('WITHDRAWN'); + }); }); describe('GET /clubs/my', () => { @@ -461,6 +521,13 @@ describe('Clubs API (integration)', () => { where: { id: create.body.id }, data: { avatarUrl: '/uploads/club-avatars/chess.png' }, }); + await prisma.clubAnnouncement.create({ + data: { + clubId: create.body.id, + userId, + content: 'Bring your student ID to the next meeting.', + }, + }); const res = await request(app) .get('/clubs/my') @@ -473,6 +540,58 @@ describe('Clubs API (integration)', () => { expect(row!.club.memberCount).toBe(1); expect(row!.club.avatarUrl).toBe('/uploads/club-avatars/chess.png'); expect(row!.nextMeeting.title).toBe('First'); + expect(row!.latestAnnouncement.content).toBe( + 'Bring your student ID to the next meeting.' + ); + expect(row!.latestAnnouncement.user.id).toBe(userId); + }); + + it('returns the latest announcement visible to the member', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + + const { token: memberToken } = await registerAndGetToken( + 'Announcement Member', + `home-announcement-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(201); + + await prisma.clubAnnouncement.create({ + data: { + clubId: create.body.id, + userId, + content: 'Public update', + visibility: 'PUBLIC', + createdAt: new Date(Date.now() - 60_000), + }, + }); + await prisma.clubAnnouncement.create({ + data: { + clubId: create.body.id, + userId, + content: 'Officer update', + visibility: 'OFFICERS', + }, + }); + + const res = await request(app) + .get('/clubs/my') + .set('Authorization', `Bearer ${memberToken}`) + .expect(200); + + const row = res.body.find((item: { club: { id: string } }) => item.club.id === create.body.id); + expect(row!.latestAnnouncement.content).toBe('Public update'); }); }); @@ -1011,6 +1130,45 @@ describe('Clubs API (integration)', () => { expect(res.body.user.name).toBeDefined(); }); + it('attaches a club meeting and can publish without notifying members', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + const meeting = await prisma.clubMeeting.create({ + data: { + clubId: create.body.id, + title: 'Photo Critique Night', + location: 'Media Center', + meetingTime: new Date(Date.now() + 24 * 60 * 60 * 1000), + createdById: userId, + }, + }); + + const res = await request(app) + .post(`/clubs/${create.body.id}/announcements`) + .set('Authorization', `Bearer ${token}`) + .send({ + content: 'Bring your favorite shots.', + meetingId: meeting.id, + notifyMembers: false, + }) + .expect(201); + expect(res.body.notifyMembers).toBe(false); + expect(res.body.meeting).toMatchObject({ + id: meeting.id, + title: 'Photo Critique Night', + location: 'Media Center', + }); + + const listed = await request(app) + .get(`/clubs/${create.body.id}/announcements`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(listed.body.items[0].meeting.id).toBe(meeting.id); + }); + it('returns 403 for MEMBER', async () => { const create = await request(app) .post('/clubs') @@ -1173,6 +1331,367 @@ describe('Clubs API (integration)', () => { }); }); + describe('POST /clubs/:id/transfer-ownership', () => { + it('transfers sole ownership, creator linkage, and audit history to a member', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + + const { token: memberToken, user: member } = await registerAndGetToken( + 'Next Owner', + `next-owner-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(201); + + const transferred = await request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: member.id }) + .expect(200); + + expect(transferred.body.previousOwner).toEqual({ + userId, + role: 'ADMIN', + }); + expect(transferred.body.newOwner.userId).toBe(member.id); + expect(transferred.body.newOwner.role).toBe('OWNER'); + expect(transferred.body.transfer.id).toEqual(expect.any(String)); + expect(transferred.body.transfer.createdAt).toEqual(expect.any(String)); + + const memberships = await prisma.clubMember.findMany({ + where: { clubId: create.body.id }, + select: { userId: true, role: true }, + }); + expect(memberships.find((row) => row.userId === userId)?.role).toBe('ADMIN'); + expect(memberships.find((row) => row.userId === member.id)?.role).toBe('OWNER'); + expect(memberships.filter((row) => row.role === 'OWNER')).toHaveLength(1); + + const club = await prisma.club.findUnique({ + where: { id: create.body.id }, + select: { createdById: true }, + }); + expect(club?.createdById).toBe(member.id); + + const audit = await prisma.clubOwnershipTransfer.findMany({ + where: { clubId: create.body.id }, + }); + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ + fromUserId: userId, + toUserId: member.id, + }); + + const history = await request(app) + .get(`/clubs/${create.body.id}/ownership-history`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(history.body.items).toHaveLength(1); + expect(history.body.items[0].fromUser.name).toBeTruthy(); + expect(history.body.items[0].toUser.name).toBe('Next Owner'); + }); + + it('rejects transfer attempts from an ADMIN', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + + const { token: adminToken, user: admin } = await registerAndGetToken( + 'Club Admin', + `transfer-admin-${Date.now()}@example.com`, + 'password123' + ); + const { token: memberToken, user: member } = await registerAndGetToken( + 'Transfer Target', + `transfer-target-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(201); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(201); + await prisma.clubMember.update({ + where: { clubId_userId: { clubId: create.body.id, userId: admin.id } }, + data: { role: 'ADMIN' }, + }); + + await request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ newOwnerUserId: member.id }) + .expect(403); + + const currentOwner = await prisma.clubMember.findUnique({ + where: { clubId_userId: { clubId: create.body.id, userId } }, + }); + expect(currentOwner?.role).toBe('OWNER'); + }); + + it('requires the new owner to be another existing member', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + + await request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: userId }) + .expect(400); + + const { user: outsider } = await registerAndGetToken( + 'Outside Owner', + `outside-owner-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: outsider.id }) + .expect(404); + }); + + it('allows only one of two concurrent transfers to commit', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + + const first = await registerAndGetToken( + 'First Candidate', + `transfer-first-${Date.now()}@example.com`, + 'password123' + ); + const second = await registerAndGetToken( + 'Second Candidate', + `transfer-second-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${first.token}`) + .expect(201); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${second.token}`) + .expect(201); + + const responses = await Promise.all([ + request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: first.user.id }), + request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: second.user.id }), + ]); + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + + const owners = await prisma.clubMember.findMany({ + where: { clubId: create.body.id, role: 'OWNER' }, + select: { userId: true }, + }); + expect(owners).toHaveLength(1); + const club = await prisma.club.findUnique({ + where: { id: create.body.id }, + select: { createdById: true }, + }); + expect(club?.createdById).toBe(owners[0].userId); + expect( + await prisma.clubOwnershipTransfer.count({ where: { clubId: create.body.id } }) + ).toBe(1); + }); + + it('keeps the transferred owner in charge when the former owner deletes their account', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + const next = await registerAndGetToken( + 'Durable Owner', + `durable-owner-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${next.token}`) + .expect(201); + await request(app) + .post(`/clubs/${create.body.id}/transfer-ownership`) + .set('Authorization', `Bearer ${token}`) + .send({ newOwnerUserId: next.user.id }) + .expect(200); + + await request(app) + .delete('/users/me') + .set('Authorization', `Bearer ${token}`) + .send({ password: 'password123' }) + .expect(204); + + const club = await prisma.club.findUnique({ + where: { id: create.body.id }, + include: { members: { where: { role: 'OWNER' } } }, + }); + expect(club?.createdById).toBe(next.user.id); + expect(club?.members.map((member) => member.userId)).toEqual([next.user.id]); + expect( + await prisma.clubOwnershipTransfer.count({ where: { clubId: create.body.id } }) + ).toBe(1); + }); + + it('hides ownership history from regular members', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + const member = await registerAndGetToken( + 'History Reader', + `history-reader-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${member.token}`) + .expect(201); + await request(app) + .get(`/clubs/${create.body.id}/ownership-history`) + .set('Authorization', `Bearer ${member.token}`) + .expect(403); + }); + }); + + describe('PATCH /clubs/:id/members/:userId/permissions', () => { + it('stores an individual officer permission override and enforces it', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + const officer = await registerAndGetToken( + 'Events Officer', + `events-officer-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${officer.token}`) + .expect(201); + await request(app) + .patch(`/clubs/${create.body.id}/members/${officer.user.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ role: 'OFFICER' }) + .expect(200); + + const permissions = await request(app) + .patch(`/clubs/${create.body.id}/members/${officer.user.id}/permissions`) + .set('Authorization', `Bearer ${token}`) + .send({ permissions: ['CREATE_MEETINGS'] }) + .expect(200); + expect(permissions.body.permissions).toEqual(['CREATE_MEETINGS']); + + await request(app) + .post(`/clubs/${create.body.id}/meetings`) + .set('Authorization', `Bearer ${officer.token}`) + .send({ + title: 'Officer-created meeting', + location: 'Ohio Union', + meetingTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }) + .expect(201); + await request(app) + .post(`/clubs/${create.body.id}/announcements`) + .set('Authorization', `Bearer ${officer.token}`) + .send({ content: 'This permission was not granted.' }) + .expect(403); + + const detail = await request(app) + .get(`/clubs/${create.body.id}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect( + detail.body.members.find((member: { userId: string }) => member.userId === officer.user.id) + .permissions + ).toEqual(['CREATE_MEETINGS']); + const officerDetail = await request(app) + .get(`/clubs/${create.body.id}`) + .set('Authorization', `Bearer ${officer.token}`) + .expect(200); + expect(officerDetail.body.myPermissions).toEqual(['CREATE_MEETINGS']); + }); + + it('rejects overrides for non-officers and regular-member actors', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + await prisma.club.update({ + where: { id: create.body.id }, + data: { isDiscoverable: true }, + }); + const member = await registerAndGetToken( + 'Permission Member', + `permission-member-${Date.now()}@example.com`, + 'password123' + ); + await request(app) + .post(`/clubs/${create.body.id}/join`) + .set('Authorization', `Bearer ${member.token}`) + .expect(201); + + await request(app) + .patch(`/clubs/${create.body.id}/members/${member.user.id}/permissions`) + .set('Authorization', `Bearer ${token}`) + .send({ permissions: ['CREATE_MEETINGS'] }) + .expect(400); + await request(app) + .patch(`/clubs/${create.body.id}/members/${userId}/permissions`) + .set('Authorization', `Bearer ${member.token}`) + .send({ permissions: ['CREATE_MEETINGS'] }) + .expect(403); + }); + }); + describe('DELETE /clubs/:id/members/:userId', () => { it('allows ADMIN to remove MEMBER', async () => { const create = await request(app) @@ -1501,6 +2020,62 @@ describe('Clubs API (integration)', () => { }); describe('Club member tags', () => { + it('persists role and custom-channel ordering', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + const firstRole = await request(app) + .post(`/clubs/${create.body.id}/roles`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Photo Team' }) + .expect(201); + const secondRole = await request(app) + .post(`/clubs/${create.body.id}/roles`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Editors' }) + .expect(201); + await request(app) + .patch(`/clubs/${create.body.id}/roles/reorder`) + .set('Authorization', `Bearer ${token}`) + .send({ roleIds: [secondRole.body.id, firstRole.body.id] }) + .expect(200); + const roles = await request(app) + .get(`/clubs/${create.body.id}/roles`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(roles.body.map((role: { id: string }) => role.id)).toEqual([ + secondRole.body.id, + firstRole.body.id, + ]); + + const firstChannel = await request(app) + .post(`/clubs/${create.body.id}/channels`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'photo-team' }) + .expect(201); + const secondChannel = await request(app) + .post(`/clubs/${create.body.id}/channels`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'editors' }) + .expect(201); + await request(app) + .patch(`/clubs/${create.body.id}/channels/reorder`) + .set('Authorization', `Bearer ${token}`) + .send({ channelIds: [secondChannel.body.id, firstChannel.body.id] }) + .expect(200); + const channels = await request(app) + .get(`/clubs/${create.body.id}/channels`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect( + channels.body.channels + .filter((channel: { kind: string }) => channel.kind === 'CUSTOM') + .map((channel: { id: string }) => channel.id) + ).toEqual([secondChannel.body.id, firstChannel.body.id]); + }); + it('lets leadership create, assign, target, and delete member tags', async () => { const create = await request(app) .post('/clubs') @@ -1619,6 +2194,27 @@ describe('Clubs API (integration)', () => { expect(row?.avatarUrl).toMatch(/\/uploads\/club-avatars\//); }); + it('allows leadership to upload a separate cover image', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + + const res = await request(app) + .patch(`/clubs/${create.body.id}`) + .set('Authorization', `Bearer ${token}`) + .field('imageKind', 'cover') + .attach('image', minimalPng, { filename: 'cover.png', contentType: 'image/png' }) + .expect(200); + + expect(res.body.coverUrl).toMatch(/\/uploads\/club-avatars\//); + expect(res.body.avatarUrl).toBeNull(); + const row = await prisma.club.findUnique({ where: { id: create.body.id } }); + expect(row?.coverUrl).toBe(res.body.coverUrl); + expect(row?.avatarUrl).toBeNull(); + }); + it('updates identity fields without requiring an image', async () => { const create = await request(app) .post('/clubs') @@ -1633,6 +2229,7 @@ describe('Clubs API (integration)', () => { .send({ name: 'Scarlet Chess', description: 'Competitive and casual chess on campus.', + category: 'Academic', isPublic: false, }) .expect(200); @@ -1640,6 +2237,7 @@ describe('Clubs API (integration)', () => { expect(res.body).toMatchObject({ name: 'Scarlet Chess', description: 'Competitive and casual chess on campus.', + category: 'Academic', isPublic: false, }); }); @@ -1742,6 +2340,88 @@ describe('Clubs API (integration)', () => { }); describe('Club visibility and officer channels', () => { + it('supports replies and reactions in club chat channels', async () => { + const create = await request(app) + .post('/clubs') + .set('Authorization', `Bearer ${token}`) + .send(validCreateBody()) + .expect(201); + const channels = await request(app) + .get(`/clubs/${create.body.id}/channels`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const general = channels.body.channels.find( + (channel: { kind: string }) => channel.kind === 'GENERAL', + ); + expect(general).toBeDefined(); + + const first = await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${token}`) + .send({ content: 'Good morning, everyone!' }) + .expect(201); + const reply = await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${token}`) + .send({ content: 'Love this shot.', replyToId: first.body.id }) + .expect(201); + expect(reply.body.replyTo).toMatchObject({ + id: first.body.id, + content: 'Good morning, everyone!', + }); + const managedImageUrl = + `https://example.supabase.co/storage/v1/object/public/club-avatars/messages/${create.body.id}/photo.jpg`; + const photo = await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${token}`) + .send({ content: '', imageUrl: managedImageUrl }) + .expect(201); + expect(photo.body).toMatchObject({ content: '', imageUrl: managedImageUrl }); + + await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${token}`) + .send({ content: 'Untrusted image', imageUrl: 'https://tracker.example/photo.jpg' }) + .expect(400); + + const reacted = await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages/${first.body.id}/reactions`) + .set('Authorization', `Bearer ${token}`) + .send({ emoji: '❀️' }) + .expect(200); + expect(reacted.body.reactions).toEqual([{ emoji: '❀️', userId }]); + + const listed = await request(app) + .get(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(listed.body.messages[0].reactions).toHaveLength(1); + expect(listed.body.messages[1].replyTo.id).toBe(first.body.id); + expect(listed.body.messages[2].imageUrl).toBe(managedImageUrl); + + const { token: memberToken, user: memberUser } = await registerAndGetToken( + 'Chat Member', + `chat-member-${Date.now()}@example.com`, + 'password123', + ); + await prisma.clubMember.create({ + data: { clubId: create.body.id, userId: memberUser.id, role: 'MEMBER' }, + }); + const memberMessage = await request(app) + .post(`/clubs/${create.body.id}/channels/${general.id}/messages`) + .set('Authorization', `Bearer ${memberToken}`) + .send({ content: 'I can remove my own message.' }) + .expect(201); + await request(app) + .delete(`/clubs/${create.body.id}/channels/${general.id}/messages/${memberMessage.body.id}`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(200); + await request(app) + .delete(`/clubs/${create.body.id}/channels/${general.id}/messages/${first.body.id}`) + .set('Authorization', `Bearer ${memberToken}`) + .expect(403); + }); + it('filters officer-only announcements for regular members', async () => { const create = await request(app) .post('/clubs') diff --git a/backend/src/routes/clubs.ts b/backend/src/routes/clubs.ts index 9c2a770..8c2b09e 100644 --- a/backend/src/routes/clubs.ts +++ b/backend/src/routes/clubs.ts @@ -11,11 +11,13 @@ import { NotificationService } from '../lib/NotificationService'; import { isSupabaseStorageConfigured, supabaseStorage } from '../lib/supabaseStorage'; import { moderateImageContent, moderateTextContent } from '../lib/contentModeration'; import { consumeDurableRateLimit } from '../lib/durableRateLimit'; +import { isValidReactionEmoji } from '../lib/reactionEmojis'; // ── Club avatar upload setup ────────────────────────────────────────────────── const CLUB_AVATAR_BUCKET = 'club-avatars'; const CLUB_AVATAR_UPLOAD_DIR = path.join(__dirname, '../../uploads/club-avatars'); +const CLUB_MESSAGE_MEDIA_FOLDER = 'messages'; const clubAvatarUpload = multer({ storage: multer.memoryStorage(), @@ -59,6 +61,29 @@ async function cleanupClubAvatarUrl(avatarUrl: string | null | undefined): Promi } } +function isManagedClubMessageImageUrl(url: string, clubId: string): boolean { + const encodedPrefix = `/${CLUB_AVATAR_BUCKET}/${CLUB_MESSAGE_MEDIA_FOLDER}/${clubId}/`; + const localPrefix = `/uploads/club-avatars/${CLUB_MESSAGE_MEDIA_FOLDER}/${clubId}/`; + return url.includes(encodedPrefix) || url.startsWith(localPrefix); +} + +async function cleanupClubMessageImageUrl(imageUrl: string | null | undefined): Promise { + if (!imageUrl) return; + if (imageUrl.startsWith('/uploads/club-avatars/')) { + const relativePath = imageUrl.slice('/uploads/club-avatars/'.length); + const absolutePath = path.resolve(CLUB_AVATAR_UPLOAD_DIR, relativePath); + if (absolutePath.startsWith(`${path.resolve(CLUB_AVATAR_UPLOAD_DIR)}${path.sep}`)) { + await fs.promises.unlink(absolutePath).catch(() => {}); + } + return; + } + if (!isSupabaseStorageConfigured()) return; + const objectName = clubSupabaseObjectNameFromPublicUrl(imageUrl); + if (objectName?.startsWith(`${CLUB_MESSAGE_MEDIA_FOLDER}/`)) { + await supabaseStorage.storage.from(CLUB_AVATAR_BUCKET).remove([objectName]).catch(() => {}); + } +} + const router = Router(); // ── OSU Campus bounds helpers ────────────────────────────────────────────────── @@ -258,10 +283,21 @@ function basePermissions(role: string): Set { return new Set(); } -function permissionsForMembership(membership: { role: string; club?: { officerPermissions?: string | null } }): Set { - const permissions = basePermissions(membership.role); +function permissionsForMembership(membership: { + role: string; + permissions?: string | null; + club?: { officerPermissions?: string | null }; +}): Set { + const permissions = + membership.role === ROLE_OFFICER && membership.permissions != null + ? new Set() + : basePermissions(membership.role); if (membership.role === ROLE_OFFICER) { - for (const permission of parseStringList(membership.club?.officerPermissions)) { + const configured = + membership.permissions != null + ? parseStringList(membership.permissions) + : parseStringList(membership.club?.officerPermissions); + for (const permission of configured) { if (CLUB_PERMISSIONS.has(permission)) permissions.add(permission); } } @@ -269,7 +305,11 @@ function permissionsForMembership(membership: { role: string; club?: { officerPe } function hasClubPermission( - membership: { role: string; club?: { officerPermissions?: string | null } } | null | undefined, + membership: { + role: string; + permissions?: string | null; + club?: { officerPermissions?: string | null }; + } | null | undefined, permission: string ): boolean { if (!membership) return false; @@ -551,10 +591,23 @@ async function isEligibleForDiscovery(club: { id: string; verification: string } async function maybeAutoEnableDiscovery(clubId: string): Promise { const club = await prisma.club.findUnique({ where: { id: clubId }, - select: { id: true, isDiscoverable: true, discoverableSince: true, status: true }, + select: { + id: true, + isDiscoverable: true, + discoveryPreference: true, + discoverableSince: true, + status: true, + }, }); if (!club) return; - if (club.isDiscoverable || club.discoverableSince || club.status !== CLUB_STATUS_ACTIVE) return; + if ( + club.discoveryPreference === 'INVITE_ONLY' || + club.isDiscoverable || + club.discoverableSince || + club.status !== CLUB_STATUS_ACTIVE + ) { + return; + } const qualifying = await countQualifyingMembers(clubId); if (qualifying < CLUB_PUBLISH_THRESHOLD) return; await prisma.club.update({ @@ -1028,6 +1081,12 @@ router.get('/my', requireAuth, async (req: AuthRequest, res: Response): Promise< } const unreadByClub = new Map(); + const latestAnnouncementByClub = new Map< + string, + Awaited>[number] & { + user: { id: string; name: string; avatarUrl: string | null }; + } + >(); await Promise.all( rows.map(async (row) => { const myRoleIds = roleIdsByClub.get(row.clubId) ?? new Set(); @@ -1047,6 +1106,19 @@ router.get('/my', requireAuth, async (req: AuthRequest, res: Response): Promise< ) ); unreadByClub.set(row.clubId, counts.reduce((sum, count) => sum + count, 0)); + + const recentAnnouncements = await prisma.clubAnnouncement.findMany({ + where: { clubId: row.clubId }, + orderBy: { createdAt: 'desc' }, + take: 20, + include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + }); + const latestVisible = recentAnnouncements.find( + (announcement) => + canAccessVisibility(announcement.visibility, true, row.role) && + canAccessTargetRoles(announcement.targetRoleIds, myRoleIds, membershipLike) + ); + if (latestVisible) latestAnnouncementByClub.set(row.clubId, latestVisible); }) ); @@ -1071,6 +1143,14 @@ router.get('/my', requireAuth, async (req: AuthRequest, res: Response): Promise< memberCount: r.club._count.members, }, nextMeeting: nextByClub.get(r.clubId) ?? null, + latestAnnouncement: latestAnnouncementByClub.has(r.clubId) + ? { + ...latestAnnouncementByClub.get(r.clubId)!, + targetRoleIds: parseStringList( + latestAnnouncementByClub.get(r.clubId)!.targetRoleIds + ), + } + : null, })) ); } catch (err) { @@ -1201,7 +1281,7 @@ router.get('/today', requireAuth, async (req: AuthRequest, res: Response): Promi router.post('/', requireAuth, async (req: AuthRequest, res: Response): Promise => { const userId = req.user!.userId; const body = req.body ?? {}; - const { name, description, category, emoji } = body; + const { name, description, category, emoji, discoveryPreference } = body; if (typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'name is required' }); @@ -1220,6 +1300,16 @@ router.post('/', requireAuth, async (req: AuthRequest, res: Response): Promise @@ -1479,7 +1580,7 @@ router.get('/:id/announcements', requireAuth, async (req: AuthRequest, res: Resp router.post('/:id/announcements', requireAuth, async (req: AuthRequest, res: Response): Promise => { const userId = req.user!.userId; const { id: clubId } = req.params; - const { content, visibility, targetRoleIds } = req.body ?? {}; + const { content, visibility, targetRoleIds, meetingId, notifyMembers } = req.body ?? {}; if (typeof content !== 'string' || !content.trim()) { res.status(400).json({ error: 'content is required' }); @@ -1496,6 +1597,14 @@ router.post('/:id/announcements', requireAuth, async (req: AuthRequest, res: Res res.status(400).json({ error: 'visibility must be PUBLIC, MEMBERS, or OFFICERS' }); return; } + if (meetingId !== undefined && meetingId !== null && typeof meetingId !== 'string') { + res.status(400).json({ error: 'meetingId must be a string' }); + return; + } + if (notifyMembers !== undefined && typeof notifyMembers !== 'boolean') { + res.status(400).json({ error: 'notifyMembers must be a boolean' }); + return; + } try { const membership = await prisma.clubMember.findUnique({ @@ -1517,6 +1626,16 @@ router.post('/:id/announcements', requireAuth, async (req: AuthRequest, res: Res res.status(400).json({ error: 'targetRoleIds must be club role ids' }); return; } + const attachedMeeting = meetingId + ? await prisma.clubMeeting.findFirst({ + where: { id: meetingId, clubId }, + select: { id: true }, + }) + : null; + if (meetingId && !attachedMeeting) { + res.status(400).json({ error: 'meetingId must belong to this club' }); + return; + } const announcement = await prisma.clubAnnouncement.create({ data: { @@ -1525,11 +1644,20 @@ router.post('/:id/announcements', requireAuth, async (req: AuthRequest, res: Res content: content.trim(), visibility: parsedVisibility, targetRoleIds: parsedTargetRoleIds.length ? stringifyStringList(parsedTargetRoleIds) : null, + meetingId: attachedMeeting?.id ?? null, + notifyMembers: notifyMembers !== false, + }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + meeting: { + select: { id: true, title: true, location: true, meetingTime: true }, + }, }, - include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); - NotificationService.notifyClubAnnouncementCreated(announcement.id, userId).catch(() => {}); + if (announcement.notifyMembers) { + NotificationService.notifyClubAnnouncementCreated(announcement.id, userId).catch(() => {}); + } res.status(201).json({ ...announcement, @@ -2322,18 +2450,19 @@ router.delete('/:id/messages/:messageId', requireAuth, async (req: AuthRequest, where: { clubId_userId: { clubId, userId } }, include: { club: { select: { officerPermissions: true } } }, }); - if (!membership || !canDeleteMessages(membership)) { - res.status(403).json({ error: 'You do not have permission to delete messages' }); - return; - } const message = await prisma.clubMessage.findFirst({ where: { id: messageId, clubId } }); if (!message) { res.status(404).json({ error: 'Message not found' }); return; } + if (!membership || (message.userId !== userId && !canDeleteMessages(membership))) { + res.status(403).json({ error: 'You do not have permission to delete this message' }); + return; + } await prisma.clubMessage.delete({ where: { id: messageId } }); + await cleanupClubMessageImageUrl(message.imageUrl); res.json({ ok: true }); } catch (err) { console.error(err); @@ -2594,6 +2723,51 @@ router.post('/:id/channels', requireAuth, async (req: AuthRequest, res: Response } }); +// PATCH /clubs/:id/channels/reorder β€” reorder custom channels (MANAGE_CLUB) +router.patch('/:id/channels/reorder', requireAuth, async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId } = req.params; + const channelIds: string[] | null = Array.isArray(req.body?.channelIds) + ? req.body.channelIds.filter((id: unknown): id is string => typeof id === 'string') + : null; + if (!channelIds || channelIds.length !== new Set(channelIds).size) { + res.status(400).json({ error: 'channelIds must be a unique array' }); + return; + } + + try { + const membership = await getMembershipWithRoles(clubId, userId); + if (!membership || !hasClubPermission(membership, PERMISSION_MANAGE_CLUB)) { + res.status(403).json({ error: 'You do not have permission to manage channels' }); + return; + } + const customChannels = await prisma.clubChannel.findMany({ + where: { clubId, kind: CHANNEL_CUSTOM }, + select: { id: true }, + }); + if ( + customChannels.length !== channelIds.length + || customChannels.some((channel) => !channelIds.includes(channel.id)) + ) { + res.status(400).json({ error: 'channelIds must include every custom channel exactly once' }); + return; + } + + await prisma.$transaction( + channelIds.map((channelId, index) => + prisma.clubChannel.update({ + where: { id: channelId }, + data: { position: 10 + index }, + }) + ) + ); + res.json({ channelIds }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // PATCH /clubs/:id/channels/:channelId β€” edit a custom channel (MANAGE_CLUB) router.patch('/:id/channels/:channelId', requireAuth, async (req: AuthRequest, res: Response): Promise => { const userId = req.user!.userId; @@ -2736,7 +2910,18 @@ router.get('/:id/channels/:channelId/messages', requireAuth, async (req: AuthReq const channelFilter = channel.kind === CHANNEL_GENERAL ? null : channel.id; const rows = await prisma.clubMessage.findMany({ where: { clubId, channelId: channelFilter }, - include: { user: { select: { id: true, name: true, avatarUrl: true } } }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + reactions: { select: { emoji: true, userId: true } }, + replyTo: { + select: { + id: true, + content: true, + userId: true, + user: { select: { id: true, name: true } }, + }, + }, + }, orderBy: { createdAt: 'asc' }, }); messages = rows.map((row) => ({ ...row, mentionRoleIds: parseStringList(row.mentionRoleIds) })); @@ -2758,22 +2943,99 @@ router.get('/:id/channels/:channelId/messages', requireAuth, async (req: AuthReq } }); +// POST /clubs/:id/channels/:channelId/messages/media β€” upload moderated chat media +router.post( + '/:id/channels/:channelId/messages/media', + requireAuth, + clubAvatarUpload.single('image'), + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId, channelId } = req.params; + if (!req.file) { + res.status(400).json({ error: 'image is required' }); + return; + } + + try { + const membership = await getMembershipWithRoles(clubId, userId); + const channel = await prisma.clubChannel.findFirst({ where: { id: channelId, clubId } }); + if (!channel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + const myRoleIds = new Set(membership?.customRoles.map((assignment) => assignment.roleId) ?? []); + if ( + !membership || + channel.kind === CHANNEL_ANNOUNCEMENTS || + channel.kind === CHANNEL_OFFICERS || + !canPostToChannel(channel, membership, myRoleIds) + ) { + res.status(403).json({ error: 'You cannot attach images in this channel' }); + return; + } + if (!isSupabaseStorageConfigured() && process.env.NODE_ENV === 'production') { + res.status(500).json({ error: 'Club media storage is not configured' }); + return; + } + + const moderation = await moderateImageContent(req.file.buffer, req.file.mimetype); + if (moderation) { + res.status(moderation.status).json({ error: moderation.message }); + return; + } + + const objectName = `${CLUB_MESSAGE_MEDIA_FOLDER}/${clubId}/${crypto.randomUUID()}.${clubAvatarExtension(req.file.mimetype)}`; + let imageUrl: string; + if (isSupabaseStorageConfigured()) { + const { error: uploadError } = await supabaseStorage.storage + .from(CLUB_AVATAR_BUCKET) + .upload(objectName, req.file.buffer, { + contentType: req.file.mimetype, + upsert: false, + }); + if (uploadError) { + res.status(500).json({ error: 'Failed to upload chat image' }); + return; + } + imageUrl = supabaseStorage.storage.from(CLUB_AVATAR_BUCKET).getPublicUrl(objectName).data.publicUrl; + } else { + const absolutePath = path.join(CLUB_AVATAR_UPLOAD_DIR, objectName); + await fs.promises.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.promises.writeFile(absolutePath, req.file.buffer); + imageUrl = `/uploads/club-avatars/${objectName}`; + } + + res.status(201).json({ imageUrl }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); + // POST /clubs/:id/channels/:channelId/messages β€” send to a chat channel (supports @role pings) router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRequest, res: Response): Promise => { const userId = req.user!.userId; const { id: clubId, channelId } = req.params; - const { content, mentionRoleIds } = req.body ?? {}; + const { content, mentionRoleIds, replyToId, imageUrl } = req.body ?? {}; const trimmed = typeof content === 'string' ? content.trim() : ''; - if (!trimmed) { - res.status(400).json({ error: 'content is required' }); + const normalizedImageUrl = typeof imageUrl === 'string' ? imageUrl.trim() : ''; + if (!trimmed && !normalizedImageUrl) { + res.status(400).json({ error: 'content or imageUrl is required' }); + return; + } + if (normalizedImageUrl && !isManagedClubMessageImageUrl(normalizedImageUrl, clubId)) { + res.status(400).json({ error: 'imageUrl must reference uploaded club media' }); return; } if (trimmed.length > MAX_CLUB_MESSAGE_LENGTH) { res.status(400).json({ error: `Message cannot exceed ${MAX_CLUB_MESSAGE_LENGTH} characters` }); return; } - const moderation = await moderateTextContent([trimmed], { allowProfanity: true }); + const moderation = trimmed + ? await moderateTextContent([trimmed], { allowProfanity: true }) + : null; if (moderation) { res.status(moderation.status).json({ error: moderation.message }); return; @@ -2790,6 +3052,10 @@ router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRe res.status(400).json({ error: 'Use the announcements endpoints for this channel' }); return; } + if (replyToId !== undefined && replyToId !== null && typeof replyToId !== 'string') { + res.status(400).json({ error: 'replyToId must be a string' }); + return; + } const myRoleIds = new Set(membership?.customRoles.map((assignment) => assignment.roleId) ?? []); if (!membership || !canPostToChannel(channel, membership, myRoleIds)) { res.status(403).json({ error: 'You do not have access to this channel' }); @@ -2817,15 +3083,39 @@ router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRe include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); } else { + const channelMessageId = channel.kind === CHANNEL_GENERAL ? null : channel.id; + const replyTo = replyToId + ? await prisma.clubMessage.findFirst({ + where: { id: replyToId, clubId, channelId: channelMessageId }, + select: { id: true }, + }) + : null; + if (replyToId && !replyTo) { + res.status(400).json({ error: 'replyToId must reference a message in this channel' }); + return; + } const created = await prisma.clubMessage.create({ data: { clubId, - channelId: channel.kind === CHANNEL_GENERAL ? null : channel.id, + channelId: channelMessageId, userId, content: trimmed, + imageUrl: normalizedImageUrl || null, mentionRoleIds: pingRoleIds.length ? stringifyStringList(pingRoleIds) : null, + replyToId: replyTo?.id ?? null, + }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + reactions: { select: { emoji: true, userId: true } }, + replyTo: { + select: { + id: true, + content: true, + userId: true, + user: { select: { id: true, name: true } }, + }, + }, }, - include: { user: { select: { id: true, name: true, avatarUrl: true } } }, }); message = { ...created, mentionRoleIds: pingRoleIds }; } @@ -2838,7 +3128,13 @@ router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRe }); if (pingRoleIds.length > 0) { - void NotificationService.notifyClubRolePing(clubId, userId, pingRoleIds, channel.name, trimmed); + void NotificationService.notifyClubRolePing( + clubId, + userId, + pingRoleIds, + channel.name, + trimmed || 'Shared a photo', + ); } res.status(201).json(message); @@ -2848,6 +3144,127 @@ router.post('/:id/channels/:channelId/messages', requireAuth, async (req: AuthRe } }); +// POST/DELETE /clubs/:id/channels/:channelId/messages/:messageId/reactions +router.post( + '/:id/channels/:channelId/messages/:messageId/reactions', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId, channelId, messageId } = req.params; + const emoji = typeof req.body?.emoji === 'string' ? req.body.emoji : ''; + if (!isValidReactionEmoji(emoji)) { + res.status(400).json({ error: 'Invalid reaction emoji' }); + return; + } + try { + const [membership, channel] = await Promise.all([ + getMembershipWithRoles(clubId, userId), + prisma.clubChannel.findFirst({ where: { id: channelId, clubId } }), + ]); + if (!channel || channel.kind === CHANNEL_ANNOUNCEMENTS || channel.kind === CHANNEL_OFFICERS) { + res.status(404).json({ error: 'Message not found' }); + return; + } + const myRoleIds = new Set(membership?.customRoles.map((assignment) => assignment.roleId) ?? []); + if (!membership || !canSeeChannel(channel, membership, myRoleIds)) { + res.status(403).json({ error: 'You do not have access to this channel' }); + return; + } + const channelFilter = channel.kind === CHANNEL_GENERAL ? null : channel.id; + const message = await prisma.clubMessage.findFirst({ + where: { id: messageId, clubId, channelId: channelFilter }, + select: { id: true }, + }); + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + await prisma.clubMessageReaction + .create({ data: { messageId, userId, emoji } }) + .catch((err: any) => { + if (err?.code !== 'P2002') throw err; + }); + const updated = await prisma.clubMessage.findUniqueOrThrow({ + where: { id: messageId }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + reactions: { select: { emoji: true, userId: true } }, + replyTo: { + select: { + id: true, + content: true, + userId: true, + user: { select: { id: true, name: true } }, + }, + }, + }, + }); + res.json({ ...updated, mentionRoleIds: parseStringList(updated.mentionRoleIds) }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +router.delete( + '/:id/channels/:channelId/messages/:messageId/reactions', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId, channelId, messageId } = req.params; + const emoji = typeof req.query.emoji === 'string' ? req.query.emoji : ''; + if (!isValidReactionEmoji(emoji)) { + res.status(400).json({ error: 'Invalid reaction emoji' }); + return; + } + try { + const [membership, channel] = await Promise.all([ + getMembershipWithRoles(clubId, userId), + prisma.clubChannel.findFirst({ where: { id: channelId, clubId } }), + ]); + if (!channel || channel.kind === CHANNEL_ANNOUNCEMENTS || channel.kind === CHANNEL_OFFICERS) { + res.status(404).json({ error: 'Message not found' }); + return; + } + const myRoleIds = new Set(membership?.customRoles.map((assignment) => assignment.roleId) ?? []); + if (!membership || !canSeeChannel(channel, membership, myRoleIds)) { + res.status(403).json({ error: 'You do not have access to this channel' }); + return; + } + const channelFilter = channel.kind === CHANNEL_GENERAL ? null : channel.id; + const message = await prisma.clubMessage.findFirst({ + where: { id: messageId, clubId, channelId: channelFilter }, + select: { id: true }, + }); + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + await prisma.clubMessageReaction.deleteMany({ where: { messageId, userId, emoji } }); + const updated = await prisma.clubMessage.findUniqueOrThrow({ + where: { id: messageId }, + include: { + user: { select: { id: true, name: true, avatarUrl: true } }, + reactions: { select: { emoji: true, userId: true } }, + replyTo: { + select: { + id: true, + content: true, + userId: true, + user: { select: { id: true, name: true } }, + }, + }, + }, + }); + res.json({ ...updated, mentionRoleIds: parseStringList(updated.mentionRoleIds) }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + // DELETE /clubs/:id/channels/:channelId/messages/:messageId β€” DELETE_MESSAGES permission router.delete( '/:id/channels/:channelId/messages/:messageId', @@ -2858,8 +3275,8 @@ router.delete( try { const membership = await getMembershipWithRoles(clubId, userId); - if (!membership || !canDeleteMessages(membership)) { - res.status(403).json({ error: 'You do not have permission to delete messages' }); + if (!membership) { + res.status(403).json({ error: 'You must be a member to delete messages' }); return; } @@ -2875,6 +3292,10 @@ router.delete( res.status(404).json({ error: 'Message not found' }); return; } + if (message.userId !== userId && !canDeleteMessages(membership)) { + res.status(403).json({ error: 'You do not have permission to delete this message' }); + return; + } await prisma.clubOfficerMessage.delete({ where: { id: messageId } }); } else { const channelFilter = channel.kind === CHANNEL_GENERAL ? null : channel.id; @@ -2885,7 +3306,12 @@ router.delete( res.status(404).json({ error: 'Message not found' }); return; } + if (message.userId !== userId && !canDeleteMessages(membership)) { + res.status(403).json({ error: 'You do not have permission to delete this message' }); + return; + } await prisma.clubMessage.delete({ where: { id: messageId } }); + await cleanupClubMessageImageUrl(message.imageUrl); } res.json({ ok: true }); } catch (err) { @@ -2968,7 +3394,7 @@ router.get('/:id/roles', requireAuth, async (req: AuthRequest, res: Response): P const roles = await prisma.clubRole.findMany({ where: { clubId }, - orderBy: { name: 'asc' }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], include: { _count: { select: { assignments: true } } }, }); @@ -3036,6 +3462,11 @@ router.post('/:id/roles', requireAuth, async (req: AuthRequest, res: Response): return; } + const lastRole = await prisma.clubRole.findFirst({ + where: { clubId }, + orderBy: [{ position: 'desc' }, { createdAt: 'desc' }], + select: { position: true }, + }); const role = await prisma.clubRole.create({ data: { clubId, @@ -3043,6 +3474,7 @@ router.post('/:id/roles', requireAuth, async (req: AuthRequest, res: Response): createdById: userId, color: parsedColor ?? null, isSelfAssignable: isSelfAssignable === true, + position: (lastRole?.position ?? -1) + 1, }, }); res.status(201).json({ ...role, permissions: [] }); @@ -3056,6 +3488,50 @@ router.post('/:id/roles', requireAuth, async (req: AuthRequest, res: Response): } }); +// PATCH /clubs/:id/roles/reorder β€” reorder named member roles +router.patch('/:id/roles/reorder', requireAuth, async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId } = req.params; + const roleIds: string[] | null = Array.isArray(req.body?.roleIds) + ? req.body.roleIds.filter((id: unknown): id is string => typeof id === 'string') + : null; + if (!roleIds || roleIds.length !== new Set(roleIds).size) { + res.status(400).json({ error: 'roleIds must be a unique array' }); + return; + } + + try { + const membership = await prisma.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId } }, + include: { club: { select: { officerPermissions: true } } }, + }); + if (!membership || !hasClubPermission(membership, PERMISSION_MANAGE_ROLES)) { + res.status(403).json({ error: 'You do not have permission to manage roles' }); + return; + } + const roles = await prisma.clubRole.findMany({ + where: { clubId }, + select: { id: true }, + }); + if ( + roles.length !== roleIds.length + || roles.some((role) => !roleIds.includes(role.id)) + ) { + res.status(400).json({ error: 'roleIds must include every role exactly once' }); + return; + } + await prisma.$transaction( + roleIds.map((roleId, index) => + prisma.clubRole.update({ where: { id: roleId }, data: { position: index } }) + ) + ); + res.json({ roleIds }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // PATCH /clubs/:id/roles/:roleId β€” rename / recolor / toggle self-assign on a member tag router.patch('/:id/roles/:roleId', requireAuth, async (req: AuthRequest, res: Response): Promise => { const userId = req.user!.userId; @@ -3370,6 +3846,229 @@ router.patch('/:id/officer-permissions', requireAuth, async (req: AuthRequest, r } }); +// PATCH /clubs/:id/members/:memberUserId/permissions β€” per-officer override +router.patch( + '/:id/members/:memberUserId/permissions', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId, memberUserId } = req.params; + const permissions = normalizePermissionList((req.body ?? {}).permissions); + if (!permissions || permissions.includes(PERMISSION_TRANSFER_OWNERSHIP)) { + res.status(400).json({ error: 'permissions must be valid club permissions' }); + return; + } + + try { + const [actor, target] = await Promise.all([ + prisma.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId } }, + }), + prisma.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId: memberUserId } }, + }), + ]); + if (!actor || ![ROLE_OWNER, ROLE_ADMIN].includes(actor.role)) { + res.status(403).json({ error: 'Only owners and admins can update officer permissions' }); + return; + } + if (!target || target.role !== ROLE_OFFICER) { + res.status(400).json({ error: 'Choose an officer to configure' }); + return; + } + + const updated = await prisma.clubMember.update({ + where: { id: target.id }, + data: { permissions: stringifyStringList(permissions) }, + select: { userId: true, permissions: true }, + }); + res.json({ + userId: updated.userId, + permissions: parseStringList(updated.permissions), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// GET /clubs/:id/ownership-history β€” current OWNER/ADMIN only +router.get( + '/:id/ownership-history', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId } = req.params; + + try { + const membership = await prisma.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId } }, + }); + if (!membership || ![ROLE_OWNER, ROLE_ADMIN].includes(membership.role)) { + res.status(403).json({ error: 'Only owners and admins can view ownership history' }); + return; + } + + const items = await prisma.clubOwnershipTransfer.findMany({ + where: { clubId }, + orderBy: { createdAt: 'desc' }, + take: 20, + include: { + fromUser: { select: { id: true, name: true, avatarUrl: true } }, + toUser: { select: { id: true, name: true, avatarUrl: true } }, + }, + }); + res.json({ items }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + +// POST /clubs/:id/transfer-ownership β€” OWNER only, atomically appoint one new owner +router.post( + '/:id/transfer-ownership', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId } = req.params; + const newOwnerUserId = + typeof (req.body ?? {}).newOwnerUserId === 'string' + ? req.body.newOwnerUserId.trim() + : ''; + + if (!newOwnerUserId) { + res.status(400).json({ error: 'newOwnerUserId is required' }); + return; + } + if (newOwnerUserId === userId) { + res.status(400).json({ error: 'Choose another club member as the new owner' }); + return; + } + + try { + const result = await prisma.$transaction(async (tx) => { + const [actor, target, existingOwners] = await Promise.all([ + tx.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId } }, + }), + tx.clubMember.findUnique({ + where: { clubId_userId: { clubId, userId: newOwnerUserId } }, + include: { + user: { select: MEMBER_USER_SELECT }, + customRoles: { + include: { role: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + }), + tx.clubMember.findMany({ + where: { clubId, role: ROLE_OWNER }, + select: { userId: true }, + }), + ]); + + if (!actor || !hasClubPermission(actor, PERMISSION_TRANSFER_OWNERSHIP)) { + return { + ok: false as const, + status: 403, + error: 'Only the current club owner can transfer ownership', + }; + } + if (!target) { + return { + ok: false as const, + status: 404, + error: 'The new owner must already be a club member', + }; + } + if (target.role === ROLE_OWNER) { + return { + ok: false as const, + status: 409, + error: 'That member is already a club owner', + }; + } + + // The conditional write is the concurrency gate: after one transfer + // demotes the actor, a competing request can no longer commit. + const actorDemotion = await tx.clubMember.updateMany({ + where: { id: actor.id, role: ROLE_OWNER }, + data: { role: ROLE_ADMIN }, + }); + if (actorDemotion.count !== 1) { + return { + ok: false as const, + status: 409, + error: 'Club ownership changed before this transfer completed', + }; + } + + // Repair legacy co-owners while preserving their club access as admins. + await tx.clubMember.updateMany({ + where: { + clubId, + role: ROLE_OWNER, + userId: { not: newOwnerUserId }, + }, + data: { role: ROLE_ADMIN }, + }); + const newOwner = await tx.clubMember.update({ + where: { id: target.id }, + data: { role: ROLE_OWNER }, + include: { + user: { select: MEMBER_USER_SELECT }, + customRoles: { + include: { role: true }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + await tx.club.update({ + where: { id: clubId }, + data: { createdById: newOwnerUserId }, + }); + const audit = await tx.clubOwnershipTransfer.create({ + data: { + clubId, + fromUserId: userId, + toUserId: newOwnerUserId, + }, + }); + + return { + ok: true as const, + previousOwner: { userId, role: ROLE_ADMIN }, + newOwner: parseMemberTags(newOwner), + transfer: { id: audit.id, createdAt: audit.createdAt }, + demotedOwnerIds: existingOwners + .map((owner) => owner.userId) + .filter((ownerId) => ownerId !== newOwnerUserId), + }; + }); + + if (!result.ok) { + res.status(result.status).json({ error: result.error }); + return; + } + + const { demotedOwnerIds, ...payload } = result; + await Promise.allSettled([ + NotificationService.notifyClubRoleChange(newOwnerUserId, clubId, ROLE_OWNER), + ...demotedOwnerIds.map((ownerId) => + NotificationService.notifyClubRoleChange(ownerId, clubId, ROLE_ADMIN) + ), + ]); + res.json(payload); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + // PATCH /clubs/:id/members/:memberUserId β€” leadership only (assign primary leadership role) router.patch( '/:id/members/:memberUserId', @@ -3591,7 +4290,7 @@ router.delete('/:id', requireAuth, async (req: AuthRequest, res: Response): Prom } }); -// PATCH /clubs/:id β€” update club profile or upload an avatar +// PATCH /clubs/:id β€” update club profile or upload an avatar/cover router.patch( '/:id', requireAuth, @@ -3603,6 +4302,7 @@ router.patch( const hasProfileFields = Object.prototype.hasOwnProperty.call(body, 'name') || Object.prototype.hasOwnProperty.call(body, 'description') || + Object.prototype.hasOwnProperty.call(body, 'category') || Object.prototype.hasOwnProperty.call(body, 'isPublic'); if (!req.file && !hasProfileFields) { @@ -3613,8 +4313,10 @@ router.patch( const updates: { name?: string; description?: string; + category?: string; isPublic?: boolean; avatarUrl?: string; + coverUrl?: string; } = {}; if (Object.prototype.hasOwnProperty.call(body, 'name')) { @@ -3631,6 +4333,13 @@ router.patch( } updates.description = body.description.trim(); } + if (Object.prototype.hasOwnProperty.call(body, 'category')) { + if (typeof body.category !== 'string' || !body.category.trim()) { + res.status(400).json({ error: 'category is required' }); + return; + } + updates.category = body.category.trim(); + } if (Object.prototype.hasOwnProperty.call(body, 'isPublic')) { if (typeof body.isPublic !== 'boolean') { res.status(400).json({ error: 'isPublic must be a boolean' }); @@ -3642,6 +4351,7 @@ router.patch( const profileModeration = await moderateTextContent([ updates.name ?? null, updates.description ?? null, + updates.category ?? null, ]); if (profileModeration) { res.status(profileModeration.status).json({ error: profileModeration.message }); @@ -3649,7 +4359,10 @@ router.patch( } try { - const club = await prisma.club.findUnique({ where: { id: clubId }, select: { id: true, avatarUrl: true } }); + const club = await prisma.club.findUnique({ + where: { id: clubId }, + select: { id: true, avatarUrl: true, coverUrl: true }, + }); if (!club) { res.status(404).json({ error: 'Club not found' }); return; @@ -3680,7 +4393,8 @@ router.patch( return; } - const filename = `${clubId}-${Date.now()}.${clubAvatarExtension(req.file.mimetype)}`; + const imageKind = body.imageKind === 'cover' ? 'cover' : 'avatar'; + const filename = `${clubId}-${imageKind}-${Date.now()}.${clubAvatarExtension(req.file.mimetype)}`; if (isSupabaseStorageConfigured()) { const { error: uploadError } = await supabaseStorage.storage @@ -3699,11 +4413,13 @@ router.patch( .from(CLUB_AVATAR_BUCKET) .getPublicUrl(filename); - updates.avatarUrl = publicUrlData.publicUrl; + if (imageKind === 'cover') updates.coverUrl = publicUrlData.publicUrl; + else updates.avatarUrl = publicUrlData.publicUrl; } else { await fs.promises.mkdir(CLUB_AVATAR_UPLOAD_DIR, { recursive: true }); await fs.promises.writeFile(path.join(CLUB_AVATAR_UPLOAD_DIR, filename), req.file.buffer); - updates.avatarUrl = `/uploads/club-avatars/${filename}`; + if (imageKind === 'cover') updates.coverUrl = `/uploads/club-avatars/${filename}`; + else updates.avatarUrl = `/uploads/club-avatars/${filename}`; } } @@ -3714,13 +4430,15 @@ router.patch( id: true, name: true, description: true, + category: true, isPublic: true, avatarUrl: true, + coverUrl: true, }, }); if (req.file) { - await cleanupClubAvatarUrl(club.avatarUrl); + await cleanupClubAvatarUrl(body.imageKind === 'cover' ? club.coverUrl : club.avatarUrl); } res.json(updated); @@ -3748,7 +4466,7 @@ router.get('/:id', requireAuth, async (req: AuthRequest, res: Response): Promise }, orderBy: { joinedAt: 'asc' }, }, - roles: { orderBy: { name: 'asc' } }, + roles: { orderBy: [{ position: 'asc' }, { createdAt: 'asc' }] }, meetings: { where: { meetingTime: { gt: now } }, orderBy: { meetingTime: 'asc' }, @@ -3812,6 +4530,8 @@ router.get('/:id', requireAuth, async (req: AuthRequest, res: Response): Promise members: club.members.map((member) => parseMemberTags({ ...member, + permissions: + member.permissions == null ? null : parseStringList(member.permissions), customRoles: member.customRoles.map((assignment) => ({ id: assignment.id, roleId: assignment.roleId, @@ -3825,6 +4545,13 @@ router.get('/:id', requireAuth, async (req: AuthRequest, res: Response): Promise ), isMember: !!myMembership, myRole: myMembership?.role ?? null, + myPermissions: myMembership + ? Array.from(permissionsForMembership({ + role: myMembership.role, + permissions: myMembership.permissions, + club: { officerPermissions: club.officerPermissions }, + })) + : [], followerCount, isFollower: !!myFollow, }); @@ -4199,6 +4926,10 @@ router.post('/:id/application-cycles', requireAuth, async (req: AuthRequest, res .map((q: unknown) => (typeof q === 'string' ? q.trim() : '')) .filter((q: string) => q.length > 0) : []; + const closesAt = + typeof body.closesAt === 'string' && body.closesAt.trim() + ? new Date(body.closesAt) + : null; if (!title) { res.status(400).json({ error: 'title is required' }); @@ -4208,6 +4939,10 @@ router.post('/:id/application-cycles', requireAuth, async (req: AuthRequest, res res.status(400).json({ error: 'Add at least one application question' }); return; } + if (closesAt && (Number.isNaN(closesAt.getTime()) || closesAt.getTime() <= Date.now())) { + res.status(400).json({ error: 'closesAt must be a future date' }); + return; + } try { if (!(await getOfficerMembership(clubId, userId))) { @@ -4227,6 +4962,7 @@ router.post('/:id/application-cycles', requireAuth, async (req: AuthRequest, res questionsJson: JSON.stringify(questions), status: CYCLE_STATUS_OPEN, opensAt: new Date(), + closesAt, }, }); await tx.club.update({ where: { id: clubId }, data: { joinPolicy: JOIN_APPLICATION } }); @@ -4256,7 +4992,12 @@ router.patch('/:id/application-cycles/:cycleId', requireAuth, async (req: AuthRe return; } - const data: { title?: string; questionsJson?: string; status?: string } = {}; + const data: { + title?: string; + questionsJson?: string; + status?: string; + closesAt?: Date | null; + } = {}; if (typeof body.title === 'string' && body.title.trim()) data.title = body.title.trim(); if (Array.isArray(body.questions)) { const qs = body.questions @@ -4264,6 +5005,18 @@ router.patch('/:id/application-cycles/:cycleId', requireAuth, async (req: AuthRe .filter((q: string) => q.length > 0); if (qs.length) data.questionsJson = JSON.stringify(qs); } + if (Object.prototype.hasOwnProperty.call(body, 'closesAt')) { + if (body.closesAt === null || body.closesAt === '') { + data.closesAt = null; + } else if (typeof body.closesAt === 'string') { + const closesAt = new Date(body.closesAt); + if (Number.isNaN(closesAt.getTime()) || closesAt.getTime() <= Date.now()) { + res.status(400).json({ error: 'closesAt must be a future date' }); + return; + } + data.closesAt = closesAt; + } + } let reopen = false; if (typeof body.status === 'string') { const status = body.status.toUpperCase(); @@ -4301,6 +5054,14 @@ router.get('/:id/application-cycles', requireAuth, async (req: AuthRequest, res: res.status(403).json({ error: 'Only officers and admins can manage applications' }); return; } + await prisma.clubApplicationCycle.updateMany({ + where: { + clubId, + status: CYCLE_STATUS_OPEN, + closesAt: { lte: new Date() }, + }, + data: { status: CYCLE_STATUS_CLOSED }, + }); const cycles = await prisma.clubApplicationCycle.findMany({ where: { clubId }, orderBy: { createdAt: 'desc' }, @@ -4320,29 +5081,58 @@ router.get('/:id/apply', requireAuth, async (req: AuthRequest, res: Response): P try { const club = await prisma.club.findUnique({ where: { id: clubId }, - select: { id: true, status: true, joinPolicy: true }, + select: { + id: true, + name: true, + category: true, + emoji: true, + avatarUrl: true, + coverUrl: true, + status: true, + joinPolicy: true, + _count: { select: { members: true } }, + }, }); if (!club || club.status === CLUB_STATUS_ARCHIVED) { res.status(404).json({ error: 'Club not found' }); return; } const openCycle = await prisma.clubApplicationCycle.findFirst({ - where: { clubId, status: CYCLE_STATUS_OPEN }, + where: { + clubId, + status: CYCLE_STATUS_OPEN, + OR: [{ closesAt: null }, { closesAt: { gt: new Date() } }], + }, orderBy: { createdAt: 'desc' }, }); const [myApplication, membership] = await Promise.all([ - openCycle - ? prisma.clubApplication.findUnique({ - where: { cycleId_userId: { cycleId: openCycle.id, userId } }, - }) - : Promise.resolve(null), + prisma.clubApplication.findFirst({ + where: { userId, cycle: { clubId } }, + orderBy: { createdAt: 'desc' }, + include: { cycle: true }, + }), prisma.clubMember.findUnique({ where: { clubId_userId: { clubId, userId } } }), ]); res.json({ joinPolicy: club.joinPolicy, isMember: !!membership, + club: { + id: club.id, + name: club.name, + category: club.category, + emoji: club.emoji, + avatarUrl: club.avatarUrl, + coverUrl: club.coverUrl, + memberCount: club._count.members, + }, openCycle: openCycle ? serializeCycle(openCycle, 0) : null, - myApplication: myApplication ? { id: myApplication.id, stage: myApplication.stage } : null, + myApplication: myApplication ? { + id: myApplication.id, + stage: myApplication.stage, + createdAt: myApplication.createdAt, + answerCount: parseJsonStringArray(myApplication.answersJson).length, + cycle: serializeCycle(myApplication.cycle, 0), + } : null, }); } catch (err) { console.error(err); @@ -4372,7 +5162,10 @@ router.post( res.status(404).json({ error: 'Application cycle not found' }); return; } - if (cycle.status !== CYCLE_STATUS_OPEN) { + if ( + cycle.status !== CYCLE_STATUS_OPEN + || (cycle.closesAt != null && cycle.closesAt.getTime() <= Date.now()) + ) { res.status(409).json({ error: 'Applications are closed.' }); return; } @@ -4401,6 +5194,38 @@ router.post( } ); +// PATCH /clubs/:id/applications/:applicationId/withdraw β€” applicant withdraws their own application +router.patch( + '/:id/applications/:applicationId/withdraw', + requireAuth, + async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + const { id: clubId, applicationId } = req.params; + try { + const application = await prisma.clubApplication.findFirst({ + where: { id: applicationId, userId, cycle: { clubId } }, + select: { id: true, stage: true }, + }); + if (!application) { + res.status(404).json({ error: 'Application not found' }); + return; + } + if (![APP_STAGE_APPLIED, APP_STAGE_INTERVIEW].includes(application.stage)) { + res.status(409).json({ error: 'This application can no longer be withdrawn' }); + return; + } + await prisma.clubApplication.update({ + where: { id: application.id }, + data: { stage: APP_STAGE_WITHDRAWN }, + }); + res.json({ ok: true, stage: APP_STAGE_WITHDRAWN }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } + } +); + // GET /clubs/:id/application-cycles/:cycleId/applications β€” officer applicant pipeline router.get( '/:id/application-cycles/:cycleId/applications', diff --git a/backend/src/routes/directMessages.ts b/backend/src/routes/directMessages.ts index d84c41d..9854259 100644 --- a/backend/src/routes/directMessages.ts +++ b/backend/src/routes/directMessages.ts @@ -87,7 +87,32 @@ router.get('/threads/:id', async (req: AuthRequest, res: Response): Promise { }); it('GET /messages/threads/:id returns messages', async () => { - const { tokenA, thread } = await makeFriends(); + const { b, tokenA, thread } = await makeFriends(); const res = await request(app) .get(`/messages/threads/${thread.id}`) .set('Authorization', `Bearer ${tokenA}`) .expect(200); expect(Array.isArray(res.body.messages)).toBe(true); expect(Array.isArray(res.body.typingUserIds)).toBe(true); + expect(res.body.otherUser).toMatchObject({ id: b.id, name: 'Bob DM' }); }); it('POST /messages/threads/:id/messages sends a message', async () => { diff --git a/backend/src/routes/pods.test.ts b/backend/src/routes/pods.test.ts index 6895b0b..5b306e2 100644 --- a/backend/src/routes/pods.test.ts +++ b/backend/src/routes/pods.test.ts @@ -75,15 +75,27 @@ describe('Pods API (integration)', () => { minMembers: 2, maxMembers: 4, meetupTime: meetupTime.toISOString(), + title: 'Late-night study sprint', + note: 'Bring a laptop and headphones.', location: validLocation, + locationAddress: '1858 Neil Avenue, Columbus, OH 43210', }) .expect(201); expect(res.body.id).toBeDefined(); expect(res.body.status).toBe('FORMING'); + expect(res.body.title).toBe('Late-night study sprint'); + expect(res.body.note).toBe('Bring a laptop and headphones.'); + expect(res.body.locationAddress).toBe('1858 Neil Avenue, Columbus, OH 43210'); expect(res.body.creatorId).toBe(userId); expect(res.body.members).toHaveLength(1); expect(res.body.activity).toBeDefined(); + + const publicPreview = await request(app) + .get(`/pods/${res.body.id}/public`) + .expect(200); + expect(publicPreview.body.podName).toBe('Late-night study sprint'); + expect(publicPreview.body.activityName).toBe(res.body.activity.title); }); it('rejects without location', async () => { @@ -578,6 +590,47 @@ describe('Pods API (integration)', () => { .set('Authorization', `Bearer ${token}`) .expect(400); }); + + it('includes myWaitlistPosition for pods the caller is waitlisted on', async () => { + const meetupTime = new Date(Date.now() + 86400000); + const createRes = await request(app) + .post('/pods/join') + .set('Authorization', `Bearer ${token}`) + .send({ + activityId, + meetupTime: meetupTime.toISOString(), + location: validLocation, + }) + .expect(201); + const podId = createRes.body.id as string; + + const { token: waiter, user: waiterUser } = await registerAndGetToken( + 'Waiter', + `waiter-list-${Date.now()}@example.com`, + 'password123' + ); + await prisma.podWaitlist.create({ + data: { podId, userId: waiterUser.id, position: 1 }, + }); + + const res = await request(app) + .get('/pods') + .query({ activityId }) + .set('Authorization', `Bearer ${waiter}`) + .expect(200); + + const listed = res.body.find((p: { id: string }) => p.id === podId); + expect(listed.myWaitlistPosition).toBe(1); + + // Creator is not waitlisted β€” null for them. + const creatorRes = await request(app) + .get('/pods') + .query({ activityId }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const creatorListed = creatorRes.body.find((p: { id: string }) => p.id === podId); + expect(creatorListed.myWaitlistPosition).toBeNull(); + }); }); describe('POST /pods/join (join existing pod)', () => { diff --git a/backend/src/routes/pods.ts b/backend/src/routes/pods.ts index 95bd5da..f8232f2 100644 --- a/backend/src/routes/pods.ts +++ b/backend/src/routes/pods.ts @@ -24,6 +24,9 @@ const CANCELLED = 'CANCELLED'; const PUBLIC_LOCATION_TYPE = 'public'; const PRIVATE_LOCATION_TYPE = 'private'; const MAX_LOCATION_LENGTH = 120; +const MAX_POD_TITLE_LENGTH = 60; +const MAX_POD_NOTE_LENGTH = 120; +const MAX_LOCATION_ADDRESS_LENGTH = 180; /** * OSU campus geofence. Roads, clockwise from the northwest corner: @@ -388,18 +391,26 @@ router.get('/', requireAuth, async (req: AuthRequest, res: Response): Promise p.id) } }, - select: { podId: true }, - }) - ).map((m) => m.podId) - ); + const podIds = result.map((p) => p.id); + const [memberRows, waitlistRows] = await Promise.all([ + prisma.podMember.findMany({ + where: { userId, podId: { in: podIds } }, + select: { podId: true }, + }), + prisma.podWaitlist.findMany({ + where: { userId, podId: { in: podIds }, status: { in: ['WAITING', 'NOTIFIED'] } }, + select: { podId: true, position: true }, + }), + ]); + const memberPodIds = new Set(memberRows.map((m) => m.podId)); + const waitlistPositions = new Map(waitlistRows.map((w) => [w.podId, w.position])); res.json( result.map((p) => { - const parsed = parsePodMembers(p); + const parsed = { + ...parsePodMembers(p), + myWaitlistPosition: waitlistPositions.get(p.id) ?? null, + }; if (!memberPodIds.has(p.id)) return approximateCoords(parsed); return parsed; }) @@ -459,7 +470,11 @@ router.post('/join', requireAuth, async (req: AuthRequest, res: Response): Promi const minMembers = Math.max(2, Math.min(10, Number(req.body.minMembers) || 2)); const maxMembers = Math.max(minMembers, Math.min(10, Number(req.body.maxMembers) || 4)); + const titleInput = typeof req.body.title === 'string' ? req.body.title.trim() : ''; + const noteInput = typeof req.body.note === 'string' ? req.body.note.trim() : ''; const locationInput = typeof req.body.location === 'string' ? req.body.location.trim() : ''; + const locationAddressInput = + typeof req.body.locationAddress === 'string' ? req.body.locationAddress.trim() : ''; const visibilityInput = typeof req.body.visibility === 'string' ? req.body.visibility.trim().toLowerCase() @@ -480,8 +495,27 @@ router.post('/join', requireAuth, async (req: AuthRequest, res: Response): Promi res.status(400).json({ error: `Location cannot exceed ${MAX_LOCATION_LENGTH} characters` }); return; } + if (titleInput.length > MAX_POD_TITLE_LENGTH) { + res.status(400).json({ error: `Pod title cannot exceed ${MAX_POD_TITLE_LENGTH} characters` }); + return; + } + if (noteInput.length > MAX_POD_NOTE_LENGTH) { + res.status(400).json({ error: `Pod note cannot exceed ${MAX_POD_NOTE_LENGTH} characters` }); + return; + } + if (locationAddressInput.length > MAX_LOCATION_ADDRESS_LENGTH) { + res.status(400).json({ + error: `Location address cannot exceed ${MAX_LOCATION_ADDRESS_LENGTH} characters`, + }); + return; + } - const moderation = await moderateTextContent([locationInput]); + const moderation = await moderateTextContent([ + titleInput, + noteInput, + locationInput, + locationAddressInput, + ]); if (moderation) { res.status(moderation.status).json({ error: moderation.message }); return; @@ -540,8 +574,11 @@ router.post('/join', requireAuth, async (req: AuthRequest, res: Response): Promi const newPod = await prisma.pod.create({ data: { activityId, + title: titleInput || null, + note: noteInput || null, meetupTime, location: locationInput, + locationAddress: locationAddressInput || null, locationType: visibilityInput, minMembers, maxMembers, @@ -1064,17 +1101,17 @@ router.get('/:id/public', async (req: Request, res: Response): Promise => } if (pod.status === EXPIRED) { - res.json({ expired: true, podName: pod.activity.title }); + res.json({ expired: true, podName: pod.title?.trim() || pod.activity.title }); return; } - const title = pod.activity.title; + const title = pod.title?.trim() || pod.activity.title; const category = pod.activity.category; res.json({ expired: false, podName: title, - activityName: title, - activityEmoji: getActivityEmoji(title, category), + activityName: pod.activity.title, + activityEmoji: getActivityEmoji(pod.activity.title, category), meetupTime: pod.meetupTime.toISOString(), location: pod.location, memberCount: pod._count.members, diff --git a/backend/src/routes/users.test.ts b/backend/src/routes/users.test.ts index 4ce98e6..1b54230 100644 --- a/backend/src/routes/users.test.ts +++ b/backend/src/routes/users.test.ts @@ -4,6 +4,103 @@ import app from '../server'; import prisma from '../prisma'; import { registerAndGetToken } from '../test/helpers'; +describe('GET /users/:id/public', () => { + it('returns a minimal profile without authentication', async () => { + const { user } = await registerAndGetToken( + 'Public Profile', + `public-profile-${Date.now()}@example.com`, + 'password123' + ); + + await prisma.user.update({ + where: { id: user.id }, + data: { + bio: 'Always up for a study break.', + instagramHandle: 'private_handle', + }, + }); + + const res = await request(app).get(`/users/${user.id}/public`).expect(200); + + expect(res.body).toMatchObject({ + id: user.id, + name: 'Public', + bio: 'Always up for a study break.', + }); + expect(res.body).not.toHaveProperty('email'); + expect(res.body).not.toHaveProperty('instagramHandle'); + expect(res.body).not.toHaveProperty('verifiedUniversity'); + }); + + it('rejects malformed IDs', async () => { + await request(app).get('/users/not-a-user/public').expect(400); + }); + + it('does not expose inactive profiles', async () => { + const { user } = await registerAndGetToken( + 'Inactive Profile', + `inactive-profile-${Date.now()}@example.com`, + 'password123' + ); + await prisma.user.update({ + where: { id: user.id }, + data: { accountStatus: 'DEACTIVATED' }, + }); + + await request(app).get(`/users/${user.id}/public`).expect(404); + }); +}); + +describe('GET /users/discover', () => { + it('returns verified suggestions with real profile context and shared interests', async () => { + const stamp = Date.now(); + const viewer = await registerAndGetToken( + 'Discover Viewer', + `discover-viewer-${stamp}@example.com`, + 'password123', + ); + const candidate = await registerAndGetToken( + 'Discover Candidate', + `discover-candidate-${stamp}@example.com`, + 'password123', + ); + + await Promise.all([ + prisma.user.update({ + where: { id: viewer.user.id }, + data: { interestTags: JSON.stringify(['Coffee', 'Music']) }, + }), + prisma.user.update({ + where: { id: candidate.user.id }, + data: { + interestTags: JSON.stringify(['Music', 'Outdoors']), + major: 'Computer Science', + classYear: 'Junior', + }, + }), + ]); + + const res = await request(app) + .get('/users/discover') + .set('Authorization', `Bearer ${viewer.token}`) + .expect(200); + + expect(res.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: candidate.user.id, + name: 'Discover Candidate', + major: 'Computer Science', + classYear: 'Junior', + sharedInterests: ['Music'], + mutualFriendCount: expect.any(Number), + }), + ]), + ); + expect(res.body.every((row: { id: string }) => row.id !== viewer.user.id)).toBe(true); + }); +}); + describe('GET /users/:id', () => { it('returns public profile with podsAttended=0 for a verified user', async () => { const { token, user } = await registerAndGetToken( @@ -23,6 +120,9 @@ describe('GET /users/:id', () => { verifiedUniversity: true, podsAttended: 0, joinedAt: expect.any(String), + mutualFriends: [], + upcomingPods: [], + clubMemberships: [], }); }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index b1a73a2..df9f8b9 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -6,6 +6,8 @@ import bcrypt from 'bcryptjs'; import prisma from '../prisma'; import { requireAuth, requireVerifiedAuth, AuthRequest } from '../middleware/auth'; import { isSupabaseStorageConfigured, supabaseStorage } from '../lib/supabaseStorage'; +import { USER_AVATAR_BUCKET, UPLOAD_DIR, cleanupAvatarUrl } from '../lib/avatarStorage'; +import { deleteUserAccount } from '../lib/accountDeletion'; import { getBlockedUserIds } from '../lib/blocks'; import { areFriends, normalizeUserPair } from '../lib/friendUtils'; import { @@ -21,6 +23,7 @@ const VALID_CLASS_YEARS = ['Freshman', 'Sophomore', 'Junior', 'Senior', 'Grad'] const MAJOR_REGEX = /^[a-zA-Z\s&\/\-,\.\(\)]+$/; const INSTAGRAM_REGEX = /^[a-zA-Z0-9._]{1,30}$/; const CLUB_REGEX = /^[a-zA-Z\s&\-]+$/; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DEFAULT_NOTIFICATION_PREFERENCES = { podJoin: true, newMessage: true, @@ -50,37 +53,10 @@ function parseNotificationPreferences(raw: string | null | undefined) { } } -function deletionSuccessorRank(role: string): number { - if (role === 'ADMIN') return 3; - if (role === 'OFFICER') return 2; - return 1; -} - const router = Router(); // ── Avatar upload setup ──────────────────────────────────────────────────────── - -const USER_AVATAR_BUCKET = 'user-avatars'; -const UPLOAD_DIR = path.join(__dirname, '../../uploads/avatars'); -// Resolved once at startup; used to guard against path traversal when deleting old avatars. -const UPLOAD_DIR_RESOLVED = path.resolve(UPLOAD_DIR); -try { - fs.mkdirSync(UPLOAD_DIR, { recursive: true }); -} catch { - // Vercel read-only filesystem β€” ignore -} - -/** - * Safely unlinks an avatar file. Resolves the stored path and verifies it - * lives inside UPLOAD_DIR before deleting β€” prevents path traversal if the - * DB record were ever tampered with. - */ -function safeUnlinkAvatar(storedUrl: string): void { - const fullPath = path.resolve(path.join(__dirname, '../../', storedUrl)); - if (fullPath.startsWith(UPLOAD_DIR_RESOLVED + path.sep)) { - fs.unlink(fullPath, () => {}); // best-effort, ignore ENOENT - } -} +// (bucket constants + cleanup helpers live in lib/avatarStorage.ts) function avatarExtension(mimetype: string): string { if (mimetype === 'image/png') return 'png'; @@ -88,27 +64,6 @@ function avatarExtension(mimetype: string): string { return 'jpg'; } -function supabaseObjectNameFromPublicUrl(url: string, bucket: string): string | null { - const marker = `/storage/v1/object/public/${bucket}/`; - const index = url.indexOf(marker); - if (index === -1) return null; - return decodeURIComponent(url.slice(index + marker.length)); -} - -async function cleanupAvatarUrl(avatarUrl: string | null | undefined): Promise { - if (!avatarUrl) return; - - const objectName = isSupabaseStorageConfigured() - ? supabaseObjectNameFromPublicUrl(avatarUrl, USER_AVATAR_BUCKET) - : null; - if (objectName) { - await supabaseStorage.storage.from(USER_AVATAR_BUCKET).remove([objectName]); - return; - } - - safeUnlinkAvatar(avatarUrl); -} - const avatarUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB @@ -751,131 +706,7 @@ router.delete('/me', requireAuth, async (req: AuthRequest, res: Response): Promi return; } - await prisma.$transaction(async (tx) => { - const ownedClubs = await tx.club.findMany({ - where: { createdById: userId }, - include: { - members: { - where: { userId: { not: userId } }, - select: { userId: true, role: true, joinedAt: true }, - }, - }, - }); - - for (const club of ownedClubs) { - const successor = [...club.members].sort((a, b) => { - const rankDifference = deletionSuccessorRank(b.role) - deletionSuccessorRank(a.role); - return rankDifference || a.joinedAt.getTime() - b.joinedAt.getTime(); - })[0]; - - if (!successor) { - await tx.club.delete({ where: { id: club.id } }); - continue; - } - - await tx.club.update({ - where: { id: club.id }, - data: { createdById: successor.userId }, - }); - await tx.clubMember.updateMany({ - where: { clubId: club.id, userId: successor.userId }, - data: { role: 'OWNER' }, - }); - await tx.clubRole.updateMany({ - where: { clubId: club.id, createdById: userId }, - data: { createdById: successor.userId }, - }); - await tx.clubMeeting.updateMany({ - where: { clubId: club.id, createdById: userId }, - data: { createdById: successor.userId }, - }); - await tx.clubMemberRole.updateMany({ - where: { clubId: club.id, assignedById: userId }, - data: { assignedById: successor.userId }, - }); - } - - const clubsWithRemainingAuthorship = await tx.club.findMany({ - where: { - OR: [ - { roles: { some: { createdById: userId } } }, - { meetings: { some: { createdById: userId } } }, - ], - }, - select: { id: true, createdById: true }, - }); - - for (const club of clubsWithRemainingAuthorship) { - await tx.clubRole.updateMany({ - where: { clubId: club.id, createdById: userId }, - data: { createdById: club.createdById }, - }); - await tx.clubMeeting.updateMany({ - where: { clubId: club.id, createdById: userId }, - data: { createdById: club.createdById }, - }); - } - - const assignmentClubIds = Array.from(new Set( - (await tx.clubMemberRole.findMany({ - where: { assignedById: userId }, - select: { clubId: true }, - })).map((assignment) => assignment.clubId) - )); - for (const clubId of assignmentClubIds) { - const club = await tx.club.findUnique({ where: { id: clubId }, select: { createdById: true } }); - if (club) { - await tx.clubMemberRole.updateMany({ - where: { clubId, assignedById: userId }, - data: { assignedById: club.createdById }, - }); - } - } - - const [podMessageIds, directMessageIds, clubMessageIds, officerMessageIds, announcementIds] = - await Promise.all([ - tx.message.findMany({ where: { userId }, select: { id: true } }), - tx.directMessage.findMany({ where: { senderId: userId }, select: { id: true } }), - tx.clubMessage.findMany({ where: { userId }, select: { id: true } }), - tx.clubOfficerMessage.findMany({ where: { userId }, select: { id: true } }), - tx.clubAnnouncement.findMany({ where: { userId }, select: { id: true } }), - ]); - - await Promise.all([ - tx.report.updateMany({ - where: { messageId: { in: podMessageIds.map((item) => item.id) } }, - data: { messageId: null }, - }), - tx.report.updateMany({ - where: { directMessageId: { in: directMessageIds.map((item) => item.id) } }, - data: { directMessageId: null }, - }), - tx.report.updateMany({ - where: { clubMessageId: { in: clubMessageIds.map((item) => item.id) } }, - data: { clubMessageId: null }, - }), - tx.report.updateMany({ - where: { clubOfficerMessageId: { in: officerMessageIds.map((item) => item.id) } }, - data: { clubOfficerMessageId: null }, - }), - tx.report.updateMany({ - where: { clubAnnouncementId: { in: announcementIds.map((item) => item.id) } }, - data: { clubAnnouncementId: null }, - }), - ]); - await tx.noShowReport.deleteMany({ - where: { OR: [{ reporterId: userId }, { targetUserId: userId }] }, - }); - - await tx.message.deleteMany({ where: { userId } }); - await tx.directMessage.deleteMany({ where: { senderId: userId } }); - await tx.clubAnnouncement.deleteMany({ where: { userId } }); - await tx.clubMessage.deleteMany({ where: { userId } }); - await tx.clubOfficerMessage.deleteMany({ where: { userId } }); - await tx.user.delete({ where: { id: userId } }); - }); - - await cleanupAvatarUrl(existing.avatarUrl); + await deleteUserAccount(userId); res.status(204).send(); } catch (err) { console.error(err); @@ -989,6 +820,116 @@ router.patch('/notifications', requireVerifiedAuth, async (req: AuthRequest, res } }); +// GET /users/discover β€” suggested students ranked by shared interests and +// mutual friends. Existing friends, pending requests, blocks, and self are +// excluded so every row can truthfully offer an Add action. +router.get('/discover', requireVerifiedAuth, async (req: AuthRequest, res: Response): Promise => { + const userId = req.user!.userId; + try { + const [viewer, blockedIds, friendships, pendingRequests] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { interestTags: true }, + }), + getBlockedUserIds(userId), + prisma.friendship.findMany({ + where: { OR: [{ userAId: userId }, { userBId: userId }] }, + select: { userAId: true, userBId: true }, + }), + prisma.friendRequest.findMany({ + where: { + status: 'PENDING', + OR: [{ senderId: userId }, { receiverId: userId }], + }, + select: { senderId: true, receiverId: true }, + }), + ]); + + const friendIds = new Set( + friendships.map((row) => (row.userAId === userId ? row.userBId : row.userAId)), + ); + const pendingIds = new Set( + pendingRequests.map((row) => (row.senderId === userId ? row.receiverId : row.senderId)), + ); + const excludedIds = new Set([userId, ...blockedIds, ...friendIds, ...pendingIds]); + const viewerInterests = new Set(parseJsonArray(viewer?.interestTags)); + + const candidates = await prisma.user.findMany({ + where: { + id: { notIn: [...excludedIds] }, + accountStatus: 'ACTIVE', + verifiedUniversity: true, + }, + select: { + id: true, + name: true, + firstName: true, + lastName: true, + avatarUrl: true, + verifiedUniversity: true, + classYear: true, + major: true, + interestTags: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 60, + }); + + const candidateIds = candidates.map((candidate) => candidate.id); + const candidateFriendships = candidateIds.length + ? await prisma.friendship.findMany({ + where: { + OR: [{ userAId: { in: candidateIds } }, { userBId: { in: candidateIds } }], + }, + select: { userAId: true, userBId: true }, + }) + : []; + + const candidateFriendIds = new Map>(); + for (const row of candidateFriendships) { + if (candidateIds.includes(row.userAId)) { + const ids = candidateFriendIds.get(row.userAId) ?? new Set(); + ids.add(row.userBId); + candidateFriendIds.set(row.userAId, ids); + } + if (candidateIds.includes(row.userBId)) { + const ids = candidateFriendIds.get(row.userBId) ?? new Set(); + ids.add(row.userAId); + candidateFriendIds.set(row.userBId, ids); + } + } + + const suggestions = candidates + .map((candidate) => { + const sharedInterests = parseJsonArray(candidate.interestTags).filter((tag) => + viewerInterests.has(tag), + ); + const mutualFriendCount = [...(candidateFriendIds.get(candidate.id) ?? [])].filter((id) => + friendIds.has(id), + ).length; + return { + ...withDisplayName(candidate, 'full'), + interestTags: undefined, + createdAt: undefined, + sharedInterests, + mutualFriendCount, + }; + }) + .sort( + (a, b) => + b.sharedInterests.length - a.sharedInterests.length || + b.mutualFriendCount - a.mutualFriendCount, + ) + .slice(0, 20); + + res.json(suggestions); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /users/search?q= β€” search users by name // NOTE: defined before /:id to avoid route conflict router.get('/search', requireVerifiedAuth, async (req: AuthRequest, res: Response): Promise => { @@ -1020,6 +961,8 @@ router.get('/search', requireVerifiedAuth, async (req: AuthRequest, res: Respons lastName: true, avatarUrl: true, verifiedUniversity: true, + classYear: true, + major: true, }, take: 20, }); @@ -1038,7 +981,17 @@ router.get('/blocked', requireVerifiedAuth, async (req: AuthRequest, res: Respon const blocks = await prisma.block.findMany({ where: { blockerId: userId }, include: { - blocked: { select: { id: true, name: true, firstName: true, lastName: true, avatarUrl: true } }, + blocked: { + select: { + id: true, + name: true, + firstName: true, + lastName: true, + avatarUrl: true, + classYear: true, + major: true, + }, + }, }, orderBy: { createdAt: 'desc' }, }); @@ -1049,6 +1002,54 @@ router.get('/blocked', requireVerifiedAuth, async (req: AuthRequest, res: Respon } }); +// GET /users/:id/public β€” minimal profile for shared web links. +// This intentionally stays separate from the authenticated profile response below: +// the landing page only needs enough information to identify the person safely. +router.get('/:id/public', async (req, res: Response): Promise => { + const targetId = req.params.id; + if (!UUID_REGEX.test(targetId)) { + res.status(400).json({ error: 'Invalid user ID' }); + return; + } + + try { + const user = await prisma.user.findFirst({ + where: { + id: targetId, + accountStatus: 'ACTIVE', + verifiedUniversity: true, + }, + select: { + id: true, + firstName: true, + lastName: true, + name: true, + avatarUrl: true, + classYear: true, + major: true, + bio: true, + }, + }); + + if (!user) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ + id: user.id, + name: getPublicName(user), + avatarUrl: user.avatarUrl ?? null, + classYear: user.classYear ?? null, + major: user.major ?? null, + bio: user.bio ?? null, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // GET /users/:id β€” public profile router.get('/:id', requireVerifiedAuth, async (req: AuthRequest, res: Response): Promise => { const targetId = req.params.id; @@ -1070,7 +1071,18 @@ router.get('/:id', requireVerifiedAuth, async (req: AuthRequest, res: Response): // from completedPodsJoined below. Before this split, podsJoined also only // counted COMPLETED pods, which read as "0 pods joined" right next to a // nonzero "people met" for any user whose pods hadn't completed yet. - const [podsJoined, completedPodsJoined, noShowPods, friendCount, sharedPodCount] = await Promise.all([ + const [ + podsJoined, + completedPodsJoined, + noShowPods, + friendCount, + sharedPodCount, + viewerFriendships, + targetFriendships, + clubMemberships, + clubCount, + upcomingPods, + ] = await Promise.all([ prisma.podMember.count({ where: { userId: targetId } }), prisma.podMember.count({ where: { userId: targetId, pod: { status: 'COMPLETED' } } }), prisma.noShowReport.groupBy({ by: ['podId'], where: { targetUserId: targetId } }), @@ -1090,8 +1102,76 @@ router.get('/:id', requireVerifiedAuth, async (req: AuthRequest, res: Response): ], }, }), + isSelf + ? Promise.resolve([]) + : prisma.friendship.findMany({ + where: { OR: [{ userAId: req.user!.userId }, { userBId: req.user!.userId }] }, + select: { userAId: true, userBId: true }, + }), + isSelf + ? Promise.resolve([]) + : prisma.friendship.findMany({ + where: { OR: [{ userAId: targetId }, { userBId: targetId }] }, + select: { userAId: true, userBId: true }, + }), + prisma.clubMember.findMany({ + where: { + userId: targetId, + club: { status: 'ACTIVE', isDiscoverable: true }, + }, + orderBy: { joinedAt: 'desc' }, + take: 4, + include: { + club: { + select: { id: true, name: true, emoji: true, avatarUrl: true, category: true }, + }, + }, + }), + prisma.clubMember.count({ + where: { + userId: targetId, + club: { status: 'ACTIVE', isDiscoverable: true }, + }, + }), + prisma.pod.findMany({ + where: { + meetupTime: { gte: new Date() }, + status: { in: ['FORMING', 'LOCKED'] }, + locationType: 'public', + members: { some: { userId: targetId } }, + }, + orderBy: { meetupTime: 'asc' }, + take: 3, + include: { + activity: { select: { title: true } }, + _count: { select: { members: true } }, + }, + }), ]); + const viewerFriendIds = new Set( + viewerFriendships.map((row) => + row.userAId === req.user!.userId ? row.userBId : row.userAId, + ), + ); + const mutualFriendIds = targetFriendships + .map((row) => (row.userAId === targetId ? row.userBId : row.userAId)) + .filter((id) => viewerFriendIds.has(id)); + const blockedIds = await getBlockedUserIds(req.user!.userId); + const visibleMutualIds = mutualFriendIds.filter((id) => !blockedIds.has(id)).slice(0, 8); + const mutualFriends = visibleMutualIds.length + ? await prisma.user.findMany({ + where: { id: { in: visibleMutualIds }, accountStatus: 'ACTIVE' }, + select: { + id: true, + name: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }) + : []; + const noShowPodCount = noShowPods.length; const podsAttended = Math.max(0, completedPodsJoined - noShowPodCount); const reliabilityScore = @@ -1110,6 +1190,8 @@ router.get('/:id', requireVerifiedAuth, async (req: AuthRequest, res: Response): joinedAt: user.createdAt.toISOString(), friendCount, sharedPodCount, + mutualFriendCount: visibleMutualIds.length, + mutualFriends: mutualFriends.map((friend) => withDisplayName(friend, 'full')), classYear: user.classYear ?? null, major: user.major ?? null, bio: user.bio ?? null, @@ -1118,6 +1200,16 @@ router.get('/:id', requireVerifiedAuth, async (req: AuthRequest, res: Response): interestTags: parseJsonArray(user.interestTags), purpose: user.purpose ?? null, campusZones: parseJsonArray(user.campusZones), + clubMemberships: clubMemberships.map(({ club }) => club), + clubCount, + upcomingPods: upcomingPods.map((pod) => ({ + id: pod.id, + title: pod.title || pod.activity.title, + meetupTime: pod.meetupTime.toISOString(), + location: pod.location, + memberCount: pod._count.members, + maxMembers: pod.maxMembers, + })), }); } catch (err) { console.error(err); diff --git a/backend/src/routes/web.test.ts b/backend/src/routes/web.test.ts index 87a35da..c7c10ae 100644 --- a/backend/src/routes/web.test.ts +++ b/backend/src/routes/web.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; import request from 'supertest'; +import jwt from 'jsonwebtoken'; import app from '../server'; import prisma from '../prisma'; +import { getJwtSecret } from '../config/jwt'; +import { createTestUser, getAuthToken } from '../test/helpers'; describe('Web / invite-link routes (public)', () => { describe('GET /.well-known/apple-app-site-association', () => { @@ -81,7 +84,7 @@ describe('Web / invite-link routes (public)', () => { const packageNames: string[] = res.body.map( (entry: { target: { package_name: string } }) => entry.target.package_name ); - expect(packageNames).toContain('com.bradyvb.ovalapp'); + expect(packageNames).toContain('com.theovalapp.app'); }); it('requires no auth token', async () => { @@ -223,4 +226,121 @@ describe('Web / invite-link routes (public)', () => { await prisma.pod.delete({ where: { id: pod.id } }); }); }); + + describe('Web account deletion flow (public, Google Play requirement)', () => { + function makeDeletionToken(userId: string, overrides: Record = {}) { + return jwt.sign( + { purpose: 'account-deletion', userId, ...overrides }, + getJwtSecret(), + { expiresIn: '1h' }, + ); + } + + describe('POST /delete-account/request', () => { + it('returns ok for an unknown email (no user enumeration)', async () => { + const res = await request(app) + .post('/delete-account/request') + .send({ email: 'nobody-here@osu.edu' }) + .expect(200); + expect(res.body).toEqual({ ok: true }); + }); + + it('returns ok for a known email without deleting anything', async () => { + const user = await createTestUser(); + const res = await request(app) + .post('/delete-account/request') + .send({ email: user.email }) + .expect(200); + expect(res.body).toEqual({ ok: true }); + + const stillThere = await prisma.user.findUnique({ where: { id: user.id } }); + expect(stillThere).not.toBeNull(); + await prisma.user.delete({ where: { id: user.id } }); + }); + + it('returns ok for malformed bodies', async () => { + const res = await request(app) + .post('/delete-account/request') + .send({ email: 12345 }) + .expect(200); + expect(res.body).toEqual({ ok: true }); + }); + }); + + describe('GET /delete-account/confirm', () => { + it('rejects a missing token', async () => { + const res = await request(app).get('/delete-account/confirm').expect(400); + expect(res.text).toContain('invalid or expired'); + }); + + it('rejects a garbage token', async () => { + const res = await request(app) + .get('/delete-account/confirm?token=not-a-real-token') + .expect(400); + expect(res.text).toContain('invalid or expired'); + }); + + it('rejects an auth-session token (wrong purpose)', async () => { + const user = await createTestUser(); + const sessionToken = getAuthToken(user.id, user.email); + const res = await request(app) + .get(`/delete-account/confirm?token=${encodeURIComponent(sessionToken)}`) + .expect(400); + expect(res.text).toContain('invalid or expired'); + await prisma.user.delete({ where: { id: user.id } }); + }); + + it('renders a confirmation form (not deletion) for a valid token', async () => { + const user = await createTestUser(); + const token = makeDeletionToken(user.id); + const res = await request(app) + .get(`/delete-account/confirm?token=${encodeURIComponent(token)}`) + .expect(200); + expect(res.text).toContain('Permanently delete'); + expect(res.text).toContain('method="POST"'); + + // GET must never delete β€” link prefetchers hit this URL. + const stillThere = await prisma.user.findUnique({ where: { id: user.id } }); + expect(stillThere).not.toBeNull(); + await prisma.user.delete({ where: { id: user.id } }); + }); + }); + + describe('POST /delete-account/confirm', () => { + it('deletes the account with a valid token', async () => { + const user = await createTestUser(); + const token = makeDeletionToken(user.id); + const res = await request(app) + .post('/delete-account/confirm') + .type('form') + .send({ token }) + .expect(200); + expect(res.text).toContain('has been deleted'); + + const gone = await prisma.user.findUnique({ where: { id: user.id } }); + expect(gone).toBeNull(); + }); + + it('rejects an invalid token without deleting', async () => { + await request(app) + .post('/delete-account/confirm') + .type('form') + .send({ token: 'garbage' }) + .expect(400); + }); + + it('is idempotent for an already-deleted account', async () => { + const user = await createTestUser(); + const token = makeDeletionToken(user.id); + await prisma.user.delete({ where: { id: user.id } }); + + const res = await request(app) + .post('/delete-account/confirm') + .type('form') + .send({ token }) + .expect(200); + expect(res.text).toContain('has been deleted'); + }); + }); + }); }); diff --git a/backend/src/routes/web.ts b/backend/src/routes/web.ts index 2fb4168..1ce75e3 100644 --- a/backend/src/routes/web.ts +++ b/backend/src/routes/web.ts @@ -1,11 +1,16 @@ -import { Router, Request, Response } from 'express'; +import express, { Router, Request, Response } from 'express'; import { Prisma } from '@prisma/client'; +import jwt from 'jsonwebtoken'; +import { rateLimit, ipKeyGenerator } from 'express-rate-limit'; import prisma from '../prisma'; +import { getJwtSecret } from '../config/jwt'; +import { deleteUserAccount } from '../lib/accountDeletion'; +import { sendAccountDeletionEmail } from '../lib/emailService'; const router = Router(); const APP_HOST = 'www.theovalapp.com'; const IOS_APP_ID = process.env.IOS_APP_ID?.trim() || 'com.bradyvb.ovalapp'; -const ANDROID_PACKAGE = process.env.ANDROID_PACKAGE?.trim() || 'com.bradyvb.ovalapp'; +const ANDROID_PACKAGE = process.env.ANDROID_PACKAGE?.trim() || 'com.theovalapp.app'; const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID?.trim() || '687FPU46UV'; const ANDROID_SHA256 = process.env.ANDROID_SHA256_CERT_FINGERPRINT?.trim(); @@ -102,8 +107,8 @@ router.get('/pod/:podId', async (req: Request, res: Response) => { const dayPart = meetup.toLocaleDateString('en-US', { weekday: 'short' }); const timePart = meetup.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); const spotsLeft = Math.max(0, pod.maxMembers - pod.members.length); - const activityTitle = pod.activity?.title ?? 'A pod'; - ogTitle = `${activityTitle} Β· ${dayPart} ${timePart}`; + const podTitle = pod.title?.trim() || pod.activity?.title || 'A pod'; + ogTitle = `${podTitle} Β· ${dayPart} ${timePart}`; ogDescription = pod.status === 'FORMING' && spotsLeft > 0 ? `${spotsLeft} ${spotsLeft === 1 ? 'spot' : 'spots'} left Β· ${pod.members.length} in β€” join on Oval` @@ -509,4 +514,177 @@ router.get('/pod/:podId', async (req: Request, res: Response) => { res.send(html); }); +// ── Public account deletion (Google Play requirement) ───────────────────────── +// Users must be able to request account deletion from the web without the app. +// Flow: landing page form β†’ POST /delete-account/request {email} β†’ tokenized +// confirmation email β†’ GET /delete-account/confirm renders a confirm page β†’ +// POST /delete-account/confirm performs the same deletion as DELETE /users/me. +// +// This router mounts BEFORE express.json() in server.ts, so body parsers are +// attached per-route here. + +const API_PUBLIC_URL = + process.env.API_PUBLIC_URL?.trim().replace(/\/$/, '') || 'https://api.theovalapp.com'; +const DELETION_TOKEN_PURPOSE = 'account-deletion'; + +const deletionLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => ipKeyGenerator(req.ip ?? ''), + message: { error: 'Too many requests, please try again later' }, + skip: () => process.env.NODE_ENV === 'test', +}); + +function verifyDeletionToken(token: string): string | null { + try { + const payload = jwt.verify(token, getJwtSecret()) as { + purpose?: string; + userId?: string; + }; + if (payload.purpose !== DELETION_TOKEN_PURPOSE || !payload.userId) return null; + return payload.userId; + } catch { + return null; + } +} + +function deletionPage(title: string, bodyHtml: string): string { + return ` + + + + + + ${title} Β· Oval + + + +
${bodyHtml}
+ +`; +} + +// Step 1: request a deletion link. Always responds identically whether or not +// the email matches an account (no user enumeration). +router.post( + '/delete-account/request', + deletionLimiter, + express.json({ limit: '4kb' }), + async (req: Request, res: Response) => { + const rawEmail = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + // Respond the same way regardless of outcome. + res.json({ ok: true }); + if (!rawEmail || rawEmail.length > 254 || !rawEmail.includes('@')) return; + + try { + const user = await prisma.user.findUnique({ + where: { email: rawEmail }, + select: { id: true, email: true }, + }); + if (!user) return; + + const token = jwt.sign( + { purpose: DELETION_TOKEN_PURPOSE, userId: user.id }, + getJwtSecret(), + { expiresIn: '1h' }, + ); + const confirmUrl = `${API_PUBLIC_URL}/delete-account/confirm?token=${encodeURIComponent(token)}`; + await sendAccountDeletionEmail(user.email, confirmUrl); + } catch (err) { + console.error('[web] Failed to process deletion request:', err); + } + }, +); + +// Step 2: the emailed link lands here. Render a confirmation page β€” deletion +// only happens on the explicit POST below, so link prefetchers can't trigger it. +router.get('/delete-account/confirm', (req: Request, res: Response) => { + const token = typeof req.query.token === 'string' ? req.query.token : ''; + const userId = token ? verifyDeletionToken(token) : null; + + res.setHeader('Content-Type', 'text/html'); + if (!userId) { + res.status(400).send( + deletionPage( + 'Link expired', + `

This link is invalid or expired

+

Deletion links expire after 1 hour. You can request a new one at + theovalapp.com/delete-account.

`, + ), + ); + return; + } + + res.send( + deletionPage( + 'Confirm deletion', + `

Permanently delete your Oval account?

+

This removes your profile, messages, pods, club memberships, and friend + connections. This cannot be undone.

+
+ + +
+

Changed your mind? Just close this page β€” nothing happens without the button.

`, + ), + ); +}); + +// Step 3: perform the deletion. +router.post( + '/delete-account/confirm', + deletionLimiter, + express.urlencoded({ extended: false, limit: '8kb' }), + async (req: Request, res: Response) => { + const token = typeof req.body?.token === 'string' ? req.body.token : ''; + const userId = token ? verifyDeletionToken(token) : null; + + res.setHeader('Content-Type', 'text/html'); + if (!userId) { + res.status(400).send( + deletionPage( + 'Link expired', + `

This link is invalid or expired

+

Deletion links expire after 1 hour. You can request a new one at + theovalapp.com/delete-account.

`, + ), + ); + return; + } + + try { + await deleteUserAccount(userId); + // Deleted now or already gone β€” either way the account no longer exists. + res.send( + deletionPage( + 'Account deleted', + `

Your account has been deleted

+

Your Oval account and personal data have been permanently removed. + Thanks for giving Oval a try β€” you're welcome back any time.

`, + ), + ); + } catch (err) { + console.error('[web] Failed to delete account via web flow:', err); + res.status(500).send( + deletionPage( + 'Something went wrong', + `

Something went wrong

+

Your account was not deleted. Please try the link again, or contact + contactus@theovalapp.com.

`, + ), + ); + } + }, +); + export default router; diff --git a/docs/CLUB_EXPERIENCE_IMPLEMENTATION_MATRIX.md b/docs/CLUB_EXPERIENCE_IMPLEMENTATION_MATRIX.md new file mode 100644 index 0000000..6171a2b --- /dev/null +++ b/docs/CLUB_EXPERIENCE_IMPLEMENTATION_MATRIX.md @@ -0,0 +1,91 @@ +# Club Experience Implementation Matrix + +This is the implementation contract for the 40 reference screens in +`docs/design-mockups-2026/club-experience-2026/screens/`. The mockups are an +art-direction source; shared production components and tokens are authoritative +when a generated board varies in spacing, radii, color, or control treatment. + +## Standardized system + +- Identity: `ClubIdentityHero`, `ClubPhoto`, and `clubIdentityImageFor` keep the + same uploaded or curated club image across home, meeting, application, roster, + and management surfaces. +- Structure: `AppBackdrop`, `ScreenHeader`, `Card`, `Sheet`, `Button`, `Chip`, + `Field`, `ContentImage`, `ClubEmptyState`, and `SegmentedControl` provide the + common spacing, typography, radii, borders, safe areas, and interaction states. +- Permissions: the backend returns effective `myPermissions`; the client hides + or disables controls from that exact list. Owner-only ownership and destructive + actions are separately gated. +- State: rich, empty, loading, restricted, error, and dark appearances use the + same production screen rather than parallel mock-only implementations. + +## Coverage + +| Reference | Production implementation | +| --- | --- | +| 01-01 Club home β€” member | `ClubHomeScreen` rich member fixture and live data | +| 01-02 Club home β€” empty | `ClubHomeScreen` empty meeting/announcement state | +| 01-03 About and roles | `ClubHomeScreen` About tab and self-assign roles | +| 01-04 Club actions | `ClubHomeScreen` action sheet | +| 02-01 General chat | `ClubChatScreen` + `MessageList`; photos, captions, replies, reactions, typing | +| 02-02 Chat empty | `ClubChatScreen` semantic chat empty state | +| 02-03 Announcements | `ClubChatScreen` announcement channel and meeting attachments | +| 02-04 Private space | `ClubChatScreen` role/officer access recovery | +| 03-01 Events | `ClubEventsScreen` search, month rail, groups, and inline RSVP | +| 03-02 Events empty | `ClubEventsScreen` permission-aware empty action | +| 03-03 Meeting detail | `MeetingDetailScreen` identity art, RSVP, sharing, calendar, and location | +| 03-04 Live check-in | `MeetingDetailScreen` live code entry and progress | +| 04-01 Members | `ClubMembersScreen` searchable, role-aware roster | +| 04-02 Member search empty | `ClubMembersScreen` semantic no-results state | +| 04-03 Apply to join | `ClubApplyScreen` current application-cycle form | +| 04-04 Application status | `ClubApplyScreen` persistent status and withdrawal | +| 05-01 Officer desk | `ClubManageScreen` metrics and grouped controls | +| 05-02 Applications | `ClubApplicationsScreen` stage queue | +| 05-03 Applicant review | `ClubApplicationsScreen` private review sheet and review note | +| 05-04 Member actions | `ClubMembersScreen` promote, permissions, tags, and removal | +| 06-01 Roles | `ClubManageScreen` role editor, colors, self-assign, and ordering | +| 06-02 Channels | `ClubManageScreen` channel editor, access targets, and ordering | +| 06-03 Officer permissions | `ClubManageScreen` per-officer permission overrides | +| 06-04 Photo and identity | `ClubManageScreen` avatar, cover, name, description, category, and visibility | +| 07-01 New announcement | `ClubChatScreen` composer, audience, meeting attachment, and notification control | +| 07-02 Create meeting | `ClubEventsScreen` meeting composer | +| 07-03 Attendance console | `MeetingDetailScreen` open/close, code, progress, reminders, and export | +| 07-04 Bulk outreach | `ClubManageScreen` audience builder and recipient preview | +| 08-01 New application cycle | `ClubApplicationsScreen` title, questions, and close date | +| 08-02 Applications empty | `ClubApplicationsScreen` applications empty state | +| 08-03 Make discoverable | `ClubVerifyPrompt` verification and eligibility flow | +| 08-04 Delete club | `ClubManageScreen` owner-gated typed confirmation | +| 09-01 Dark club home | Theme-driven `ClubHomeScreen` | +| 09-02 Dark general chat | Theme-driven `ClubChatScreen` | +| 09-03 Dark meeting | Theme-driven `MeetingDetailScreen` | +| 09-04 Dark officer desk | Theme-driven `ClubManageScreen` | +| 10-01 Club load error | `ClubHomeScreen` recovery state and retry | +| 10-02 Meeting not found | `MeetingDetailScreen` recovery state | +| 10-03 Applications closed | `ClubApplyScreen` closed-cycle state | +| 10-04 Leader access required | `ClubManageScreen` restricted state | + +## Added beyond the reference set + +- Transfer ownership is available from the Officer desk to the current owner. + The flow requires an eligible member selection and exact club-name + confirmation, demotes the former owner to admin, repairs the club creator + pointer, records immutable history, and is protected against concurrent + transfers. +- Club-chat photos are moderated before storage, restricted to managed club + media URLs, and removed from storage when a moderator deletes the message. + +## Verification + +- Backend club route suite: 74 tests. +- Full backend suite: 433 tests; frontend suite: 29 tests. +- Prisma production-baseline-to-current migration check. +- Backend TypeScript build, frontend TypeScript check, and landing production build. +- Repository release-policy check under Node 22. +- Expo Doctor: 21 of 21 checks; production iOS export completed. +- Production dependency audits: zero findings in backend, frontend, and landing. +- Deterministic development routes cover every one of the 40 references, + including member, empty, restricted, officer-only, destructive, recovery, + and dark-mode states. A 390Γ—844 browser render audit captured all 40 without + a runtime failure and visually checked the ten four-screen boards. +- Direct iOS Simulator renders of the light and dark member home, Officer desk, + ownership transfer sheet, and media-rich general chat. diff --git a/docs/COLD_START_REVAMP_2026-07-25.md b/docs/COLD_START_REVAMP_2026-07-25.md new file mode 100644 index 0000000..04d27f2 --- /dev/null +++ b/docs/COLD_START_REVAMP_2026-07-25.md @@ -0,0 +1,375 @@ +# Oval β€” Cold-Start & Clubs Revamp + +**Written:** July 25, 2026 Β· **Owner:** Brady Β· **Target:** substantially complete before the ~Aug 10 Play submission, fully live for welcome week (Aug 19–25). + +**Companion docs:** `docs/STATE_2026-07-06.md` (backlog), `docs/GROWTH_PLAN.md` (measurement), `docs/LAUNCH_PLAN_FALL2026.md` (external deadlines), `docs/wireframes-2026/clubs-directions-2026-07.html` (clubs layout studies), `ARCHITECTURE.md` Part 0. + +--- + +## 0. Thesis + +Oval's home board goes empty because `Pod` is perishable by construction: it requires a future `meetupTime` and `expireOldPods()` sweeps it afterward. Steady-state board size is *creation rate Γ— lifetime*. At launch-scale N with a lifetime measured in days, that product is regularly zero. No amount of empty-state copy changes the arithmetic. + +Three structural fixes, in order of leverage: + +1. **Collect intent instead of supply.** Creating a pod is a public bid that can visibly fail; that social risk β€” not effort β€” is why nobody goes first. A demand signal is cheap, private, and reversible. The system, not a brave student, crystallizes the pod. +2. **Make signup itself supply.** If a new user's first action deposits demand signals, the board fills from the one event welcome week guarantees. Cold start flips from "Nβˆ’1 users must act before user N gets value" to "user 1 contributes on arrival." +3. **Make emptiness legible.** A scheduled formation window ("drop") means a quiet Tuesday reads as *waiting*, not *dead*. + +Everything below serves those three. Pods are not removed and are not buried inside clubs; clubs become a **scope** on pods, which shrinks the denominator so demand counts read as strong instead of as evidence of abandonment. + +--- + +## 1. What already exists β€” do not rebuild + +The demand mechanic is largely built and shipped. Read these before writing anything. + +| Thing | Location | +|---|---| +| `PodDemand` model (7-day TTL, `consumedAt`, unique `[userId, activityId]`) | `backend/prisma/schema.prisma` | +| `POST` / `DELETE /activities/:id/demand`, `DEMAND_TTL_MS` | `backend/src/routes/activities.ts:26,111,163` | +| `demandCount` + `myDemanded` on the activities list | `backend/src/routes/activities.ts:234-259` | +| `DEMAND_POOL_THRESHOLD = 4`, `sendDemandConversionPrompts()` | `backend/src/lib/NotificationService.ts:60,757` | +| Demand marked consumed on join | `backend/src/lib/joinExistingPod.ts:139` | +| Job runner (cron entry point) | `backend/src/lib/maintenanceJobs.ts` | +| `demandAlerts` notification preference | `NotificationService.ts:25`, `frontend/src/api.ts:1198` | +| Client demand calls | `frontend/src/api.ts:345,351` | +| Demand UI (chips) | `frontend/src/screens/ExploreScreen.tsx:347,666`, `ActivityPodsScreen.tsx:247` | +| Interest taxonomy `INTEREST_TAGS` β†’ `TAG_TO_CATEGORY` | `backend/src/config/interestTags.ts` | +| Club categories | `frontend/src/constants/clubCategories.ts` | +| Club liveness data sources | `GET /clubs/today`, `/clubs/week`, `getMyClubs`, `nextMeeting`, `attendeeCount`, `upcomingMeetingCount` | +| Unread source of truth | `GET /inbox/summary` (`backend/src/routes/inbox.ts:7`) | +| Attendance confirmation primitive | `PodMember.confirmedAt` (exists, currently unused for this) | + +**The single largest defect today:** none of the demand UI appears on `HomeScreen.tsx`. The screen where a user hits the empty board offers "Start a pod" β€” the highest-friction, highest-social-risk action in the app β€” while the zero-risk action sits one tab away behind a category browse. Fixing that placement is worth more than any other item in this document. + +**The single largest defect on the clubs screen:** `ClubsHomeScreen.tsx:129` sorts Discover by `memberCount` descending β€” a static ranking. The screen is byte-identical every time it opens. That, plus rows that carry no content (mark + name + one administrative caption + chevron), is why it reads as a utility list. + +--- + +## 2. Phase 0 β€” Unify the taxonomy + +Everything downstream depends on one shared vocabulary. Do this first; it is small and it is load-bearing. + +Today there are two layers: `INTEREST_TAGS` (16 personal tags) β†’ `TAG_TO_CATEGORY` β†’ club categories. Add a third layer **above** them: **intents**, the entry point for both club discovery and pod demand. + +Create `backend/src/config/intents.ts` (and mirror the constant to the frontend, or expose via an endpoint β€” do not fork the list): + +```ts +export const INTENTS = { + MAKE: { label: 'Make something', blurb: 'Film, music, art', tags: ['Art','Dance','Film','Music','Photography'] }, + MOVE: { label: 'Move together', blurb: 'Sports, outdoors, gym', tags: ['Gym','Outdoors','Sports'] }, + BUILD: { label: 'Build your future',blurb: 'STEM, business', tags: ['Engineering','Pre-Med','Tech','Reading'] }, + GIVE: { label: 'Give something', blurb: 'Service, advocacy', tags: ['Volunteering'] }, + HANG: { label: 'Just hang out', blurb: 'Food, games, people', tags: ['Food','Gaming','Greek Life'] }, +} as const; +``` + +Every tag in `INTEREST_TAGS` must appear in exactly one intent β€” add a unit test asserting total coverage and no duplicates, so the lists can't drift. + +Intents drive: the Clubs β†’ Discover tiles, the onboarding step 1, and the activity ordering on Home. `getInterestCategories()` continues to work unchanged; intents resolve *through* tags to categories. + +--- + +## 3. Schema changes + +One migration (`npm run db:migrate`). Deploys already run `prisma migrate deploy` per `backend/vercel.json`. + +```prisma +model PodDemand { + // existing: id, userId, activityId, createdAt, expiresAt, consumedAt + windows String @default("[]") // JSON array of WindowKey + clubId String? // club-scoped pool + clubMeetingId String? // "find people to go with" on a specific meeting + + club Club? @relation(fields: [clubId], references: [id], onDelete: Cascade) + meeting ClubMeeting? @relation(fields: [clubMeetingId], references: [id], onDelete: Cascade) + + @@unique([userId, activityId, clubId, clubMeetingId]) // replaces @@unique([userId, activityId]) + @@index([activityId, clubId, expiresAt]) +} + +model Pod { + // existing fields unchanged + origin String @default("USER") // USER | DEMAND | DROP | CLUB + clubId String? + clubMeetingId String? + formedAt DateTime? // when auto-formation created it + confirmDeadline DateTime? // T-3h gate; null for USER pods + + club Club? @relation(fields: [clubId], references: [id], onDelete: SetNull) + meeting ClubMeeting? @relation(fields: [clubMeetingId], references: [id], onDelete: SetNull) + + @@index([clubId]) +} +``` + +`Pod.status` gains a `DISSOLVED` value (string column today β€” no enum migration needed, but update every `status` filter: `routes/pods.ts` feed query, `expireOldPods.ts`, `NotificationService` reminder queries, and the public preview route). + +`Pod.creatorId` is already nullable β€” **auto-formed pods leave it null**. That null is the mechanism: nobody went first because nobody had to. + +Backfill: existing rows get `origin: 'USER'`, `windows: '[]'`. No data migration risk. + +--- + +## 4. Backend + +### 4.1 Time windows + +`backend/src/config/windows.ts`. All resolution in Columbus time β€” `TZ=America/New_York` is forced in `server.ts` and `vercel.json`; never construct these in UTC. + +```ts +export const WINDOWS = { + WEEKDAY_MORNING: { label: 'Weekday mornings', days: [1,2,3,4,5], hour: 8 }, + WEEKDAY_AFTERNOON:{ label: 'Weekday afternoons', days: [1,2,3,4,5], hour: 15 }, + WEEKNIGHT: { label: 'Weeknights', days: [1,2,3,4], hour: 19 }, + WEEKEND_DAY: { label: 'Weekend days', days: [0,6], hour: 14 }, + WEEKEND_NIGHT: { label: 'Weekend nights', days: [5,6], hour: 21 }, +} as const; +``` + +`nextSlot(window, notBefore)` returns the next matching day/hour at least 12 hours out β€” enough notice that a formed group is actionable. + +`POST /activities/:id/demand` gains an optional `windows: WindowKey[]` body field, validated against the keys (the global `inputGuard` handles structure; validate membership explicitly). Empty array = "any time," which matches every window during formation. + +**This is the crucial ordering decision:** windows are collected *at signal time*, not after the pool fills. A post-threshold scheduling round is a second round-trip that requires everyone to respond again, and at launch N those pools will die at that step β€” failing later and louder than not forming at all. + +### 4.2 Automatic formation + +New `backend/src/lib/formPodsFromDemand.ts`, called from `maintenanceJobs.ts`. + +``` +for each (activityId, clubId, window) group over PodDemand + where consumedAt IS NULL and expiresAt > now: + + if group.size < DEMAND_FORM_THRESHOLD (3 at launch) β†’ skip + if an open FORMING pod already exists for that activity β†’ skip (reuse the + existing check in sendDemandConversionPrompts:791-799) + if any member already has an auto-formed pod today β†’ skip that member + + create Pod { + activityId, clubId, + meetupTime: nextSlot(window), + location: activity.defaultLocation, + locationType: 'public', + creatorId: null, + origin: 'DEMAND' | 'DROP', + status: 'FORMING', + formedAt: now, + confirmDeadline: meetupTime - 3h, + } + insert PodMember rows (confirmedAt: null) up to maxMembers; + overflow spills into a second pod rather than being dropped + mark those PodDemand rows consumed + push 'pod_auto_formed' β†’ "Your group locked in: ,