From 3e86e0ffc055ce4878447f66bc8f1c3e1216aab3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 17:10:27 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=2011=20?= =?UTF-8?q?=E2=80=94=20Memories=20/=20Gallery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GalleryAlbum + GalleryItem models + migration (publish gate, cover image, ordered items) - GalleryModule: admin CRUD for albums + items - Member endpoints: GET /my/memories (published albums w/ cover + count), GET /my/memories/:id (album + photos) - /my/memories page: album grid with cover thumbnails - /my/memories/[id] page: photo grid with captions - Memories nav item; register GalleryModule Completes Phase 2 (Intelligence) of the member portal — Sprints 9-11. Co-Authored-By: Claude Sonnet 4.6 --- .../20260617260000_add_gallery/migration.sql | 31 +++++ apps/api/prisma/schema.prisma | 38 ++++++ apps/api/src/app.module.ts | 2 + .../src/modules/gallery/gallery.controller.ts | 55 ++++++++ .../api/src/modules/gallery/gallery.module.ts | 12 ++ .../src/modules/gallery/gallery.service.ts | 120 ++++++++++++++++++ apps/api/src/modules/my/my.controller.ts | 12 ++ apps/api/src/modules/my/my.module.ts | 3 +- apps/web/app/api/my/memories/[id]/route.ts | 13 ++ apps/web/app/api/my/memories/route.ts | 9 ++ apps/web/app/my/layout.tsx | 1 + apps/web/app/my/memories/[id]/page.tsx | 58 +++++++++ apps/web/app/my/memories/page.tsx | 70 ++++++++++ 13 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260617260000_add_gallery/migration.sql create mode 100644 apps/api/src/modules/gallery/gallery.controller.ts create mode 100644 apps/api/src/modules/gallery/gallery.module.ts create mode 100644 apps/api/src/modules/gallery/gallery.service.ts create mode 100644 apps/web/app/api/my/memories/[id]/route.ts create mode 100644 apps/web/app/api/my/memories/route.ts create mode 100644 apps/web/app/my/memories/[id]/page.tsx create mode 100644 apps/web/app/my/memories/page.tsx diff --git a/apps/api/prisma/migrations/20260617260000_add_gallery/migration.sql b/apps/api/prisma/migrations/20260617260000_add_gallery/migration.sql new file mode 100644 index 0000000..2d01f64 --- /dev/null +++ b/apps/api/prisma/migrations/20260617260000_add_gallery/migration.sql @@ -0,0 +1,31 @@ +CREATE TABLE "GalleryAlbum" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "coverUrl" TEXT, + "isPublished" BOOLEAN NOT NULL DEFAULT false, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "GalleryAlbum_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "GalleryItem" ( + "id" TEXT NOT NULL, + "albumId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "mediaUrl" TEXT NOT NULL, + "caption" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "GalleryItem_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "GalleryAlbum_tenantId_isPublished_idx" ON "GalleryAlbum"("tenantId", "isPublished"); +CREATE INDEX "GalleryItem_albumId_idx" ON "GalleryItem"("albumId"); +CREATE INDEX "GalleryItem_tenantId_idx" ON "GalleryItem"("tenantId"); + +ALTER TABLE "GalleryAlbum" ADD CONSTRAINT "GalleryAlbum_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "GalleryItem" ADD CONSTRAINT "GalleryItem_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "GalleryAlbum"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "GalleryItem" ADD CONSTRAINT "GalleryItem_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1de23ca..8b04ca9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -98,6 +98,8 @@ model Tenant { askAIQueries AskAIQuery[] knowledgeBoards KnowledgeBoard[] knowledgeItems KnowledgeItem[] + galleryAlbums GalleryAlbum[] + galleryItems GalleryItem[] } enum AdminRole { @@ -799,3 +801,39 @@ model KnowledgeItem { @@index([tenantId, status]) @@index([boardId, status]) } + +// ============================================================================ +// Memories / Gallery +// ============================================================================ + +model GalleryAlbum { + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + title String + description String? + coverUrl String? + isPublished Boolean @default(false) + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items GalleryItem[] + + @@index([tenantId, isPublished]) +} + +model GalleryItem { + id String @id @default(cuid()) + albumId String + album GalleryAlbum @relation(fields: [albumId], references: [id], onDelete: Cascade) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + mediaUrl String + caption String? + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + @@index([albumId]) + @@index([tenantId]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d06e5eb..2f6db05 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -22,6 +22,7 @@ import { GamificationModule } from './modules/gamification/gamification.module'; import { SevaModule } from './modules/seva/seva.module'; import { CirclesModule } from './modules/circles/circles.module'; import { KnowledgeModule } from './modules/knowledge/knowledge.module'; +import { GalleryModule } from './modules/gallery/gallery.module'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { KnowledgeModule } from './modules/knowledge/knowledge.module'; SevaModule, CirclesModule, KnowledgeModule, + GalleryModule, ], }) export class AppModule {} diff --git a/apps/api/src/modules/gallery/gallery.controller.ts b/apps/api/src/modules/gallery/gallery.controller.ts new file mode 100644 index 0000000..cee422b --- /dev/null +++ b/apps/api/src/modules/gallery/gallery.controller.ts @@ -0,0 +1,55 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { GalleryService } from './gallery.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { CurrentTenantContext } from '../auth/current-tenant.decorator'; +import { TenantContext } from '../../common/tenant-context'; + +@Controller('admin/gallery') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('OWNER', 'ADMIN') +export class GalleryController { + constructor(private readonly service: GalleryService) {} + + @Get('albums') + listAlbums(@CurrentTenantContext() ctx: TenantContext) { + return this.service.listAlbumsAdmin(ctx.tenantId); + } + + @Post('albums') + createAlbum( + @CurrentTenantContext() ctx: TenantContext, + @Body() body: { title: string; description?: string; coverUrl?: string; isPublished?: boolean }, + ) { + return this.service.createAlbum(ctx.tenantId, ctx.adminId!, body); + } + + @Patch('albums/:id') + updateAlbum( + @CurrentTenantContext() ctx: TenantContext, + @Param('id') id: string, + @Body() body: { title?: string; description?: string; coverUrl?: string; isPublished?: boolean }, + ) { + return this.service.updateAlbum(ctx.tenantId, id, body); + } + + @Delete('albums/:id') + removeAlbum(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { + return this.service.removeAlbum(ctx.tenantId, id); + } + + @Post('albums/:id/items') + addItem( + @CurrentTenantContext() ctx: TenantContext, + @Param('id') id: string, + @Body() body: { mediaUrl: string; caption?: string; sortOrder?: number }, + ) { + return this.service.addItem(ctx.tenantId, id, body); + } + + @Delete('items/:itemId') + removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) { + return this.service.removeItem(ctx.tenantId, itemId); + } +} diff --git a/apps/api/src/modules/gallery/gallery.module.ts b/apps/api/src/modules/gallery/gallery.module.ts new file mode 100644 index 0000000..d75aea6 --- /dev/null +++ b/apps/api/src/modules/gallery/gallery.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GalleryController } from './gallery.controller'; +import { GalleryService } from './gallery.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [GalleryController], + providers: [GalleryService], + exports: [GalleryService], +}) +export class GalleryModule {} diff --git a/apps/api/src/modules/gallery/gallery.service.ts b/apps/api/src/modules/gallery/gallery.service.ts new file mode 100644 index 0000000..363e458 --- /dev/null +++ b/apps/api/src/modules/gallery/gallery.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class GalleryService { + constructor(private readonly prisma: PrismaService) {} + + // ── Admin: albums ────────────────────────────────────────────────────────── + + async listAlbumsAdmin(tenantId: string) { + return this.prisma.galleryAlbum.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { items: true } } }, + }); + } + + async createAlbum(tenantId: string, adminId: string, dto: { + title: string; + description?: string; + coverUrl?: string; + isPublished?: boolean; + }) { + return this.prisma.galleryAlbum.create({ + data: { + tenantId, + title: dto.title, + description: dto.description ?? null, + coverUrl: dto.coverUrl ?? null, + isPublished: dto.isPublished ?? false, + createdBy: adminId, + }, + }); + } + + async updateAlbum(tenantId: string, id: string, dto: { + title?: string; + description?: string; + coverUrl?: string; + isPublished?: boolean; + }) { + const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } }); + if (!album) throw new NotFoundException('Album not found'); + return this.prisma.galleryAlbum.update({ + where: { id }, + data: { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.coverUrl !== undefined && { coverUrl: dto.coverUrl }), + ...(dto.isPublished !== undefined && { isPublished: dto.isPublished }), + }, + }); + } + + async removeAlbum(tenantId: string, id: string) { + const album = await this.prisma.galleryAlbum.findFirst({ where: { id, tenantId } }); + if (!album) throw new NotFoundException('Album not found'); + await this.prisma.galleryAlbum.delete({ where: { id } }); + return { ok: true }; + } + + // ── Admin: items ───────────────────────────────────────────────────────────── + + async addItem(tenantId: string, albumId: string, dto: { mediaUrl: string; caption?: string; sortOrder?: number }) { + const album = await this.prisma.galleryAlbum.findFirst({ where: { id: albumId, tenantId } }); + if (!album) throw new NotFoundException('Album not found'); + return this.prisma.galleryItem.create({ + data: { + albumId, + tenantId, + mediaUrl: dto.mediaUrl, + caption: dto.caption ?? null, + sortOrder: dto.sortOrder ?? 0, + }, + }); + } + + async removeItem(tenantId: string, itemId: string) { + const item = await this.prisma.galleryItem.findFirst({ where: { id: itemId, tenantId } }); + if (!item) throw new NotFoundException('Item not found'); + await this.prisma.galleryItem.delete({ where: { id: itemId } }); + return { ok: true }; + } + + // ── Member: read published albums ──────────────────────────────────────────── + + async listForMember(tenantId: string) { + const albums = await this.prisma.galleryAlbum.findMany({ + where: { tenantId, isPublished: true }, + orderBy: { createdAt: 'desc' }, + include: { _count: { select: { items: true } } }, + }); + return albums.map((a) => ({ + id: a.id, + title: a.title, + description: a.description, + coverUrl: a.coverUrl, + itemCount: a._count.items, + createdAt: a.createdAt.toISOString(), + })); + } + + async getAlbumForMember(tenantId: string, albumId: string) { + const album = await this.prisma.galleryAlbum.findFirst({ + where: { id: albumId, tenantId, isPublished: true }, + include: { items: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] } }, + }); + if (!album) throw new NotFoundException('Album not found'); + return { + id: album.id, + title: album.title, + description: album.description, + items: album.items.map((i) => ({ + id: i.id, + mediaUrl: i.mediaUrl, + caption: i.caption, + })), + }; + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index ade36a9..1c91705 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -4,6 +4,7 @@ import { SevaService } from '../seva/seva.service'; import { CirclesService } from '../circles/circles.service'; import { AskAIService } from '../ask-ai/ask-ai.service'; import { KnowledgeService } from '../knowledge/knowledge.service'; +import { GalleryService } from '../gallery/gallery.service'; import { MemberAuth } from '../auth/member-auth.decorator'; import { CurrentMember } from '../auth/current-member.decorator'; import type { MemberJwtPayload } from '@tower/types'; @@ -32,6 +33,7 @@ export class MyController { private readonly circles: CirclesService, private readonly askAI: AskAIService, private readonly knowledge: KnowledgeService, + private readonly gallery: GalleryService, ) {} @Get('knowledge') @@ -39,6 +41,16 @@ export class MyController { return this.knowledge.listForMember(member.tenantId, q); } + @Get('memories') + memoriesList(@CurrentMember() member: MemberJwtPayload) { + return this.gallery.listForMember(member.tenantId); + } + + @Get('memories/:id') + memoriesAlbum(@CurrentMember() member: MemberJwtPayload, @Param('id') id: string) { + return this.gallery.getAlbumForMember(member.tenantId, id); + } + @Get('ask') askHistory(@CurrentMember() member: MemberJwtPayload) { return this.askAI.history(member.sub, member.tenantId); diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts index c0326dc..2f9c800 100644 --- a/apps/api/src/modules/my/my.module.ts +++ b/apps/api/src/modules/my/my.module.ts @@ -7,9 +7,10 @@ import { GamificationModule } from '../gamification/gamification.module'; import { CirclesModule } from '../circles/circles.module'; import { AskAIModule } from '../ask-ai/ask-ai.module'; import { KnowledgeModule } from '../knowledge/knowledge.module'; +import { GalleryModule } from '../gallery/gallery.module'; @Module({ - imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule], + imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule, GalleryModule], controllers: [MyController], providers: [MyService], }) diff --git a/apps/web/app/api/my/memories/[id]/route.ts b/apps/web/app/api/my/memories/[id]/route.ts new file mode 100644 index 0000000..3b2220d --- /dev/null +++ b/apps/web/app/api/my/memories/[id]/route.ts @@ -0,0 +1,13 @@ +import { jsonResponse, memberApiFetch } from '../../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const res = await memberApiFetch(`/my/memories/${id}`); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/api/my/memories/route.ts b/apps/web/app/api/my/memories/route.ts new file mode 100644 index 0000000..409ca1a --- /dev/null +++ b/apps/web/app/api/my/memories/route.ts @@ -0,0 +1,9 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(): Promise { + const res = await memberApiFetch('/my/memories'); + const body = await res.json(); + return jsonResponse(body, res.status); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index b13af5a..f55fce4 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -11,6 +11,7 @@ const NAV = [ { href: '/my/seva', label: 'Seva & Points' }, { href: '/my/circles', label: 'Circles' }, { href: '/my/directory', label: 'Directory' }, + { href: '/my/memories', label: 'Memories' }, { href: '/my/groups', label: 'My Groups' }, { href: '/my/settings', label: 'Settings' }, ]; diff --git a/apps/web/app/my/memories/[id]/page.tsx b/apps/web/app/my/memories/[id]/page.tsx new file mode 100644 index 0000000..9581574 --- /dev/null +++ b/apps/web/app/my/memories/[id]/page.tsx @@ -0,0 +1,58 @@ +import { getMemberToken, getApiBaseUrl } from '../../../_lib/api'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; + +interface AlbumDetail { + id: string; + title: string; + description: string | null; + items: Array<{ id: string; mediaUrl: string; caption: string | null }>; +} + +async function fetchAlbum(token: string, id: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/memories/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return null; + return (await res.json()) as AlbumDetail; +} + +export default async function AlbumPage({ params }: { params: Promise<{ id: string }> }) { + const token = await getMemberToken(); + if (!token) return null; + + const { id } = await params; + const album = await fetchAlbum(token, id); + if (!album) notFound(); + + return ( +
+
+
+

{album.title}

+ {album.description &&

{album.description}

} +
+ ← Albums +
+ + {album.items.length === 0 ? ( +

This album has no photos yet.

+ ) : ( +
+ {album.items.map((item) => ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.caption +
+ {item.caption && ( +
{item.caption}
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/my/memories/page.tsx b/apps/web/app/my/memories/page.tsx new file mode 100644 index 0000000..dafaafa --- /dev/null +++ b/apps/web/app/my/memories/page.tsx @@ -0,0 +1,70 @@ +import { getMemberToken, getApiBaseUrl } from '../../_lib/api'; +import Link from 'next/link'; + +interface Album { + id: string; + title: string; + description: string | null; + coverUrl: string | null; + itemCount: number; + createdAt: string; +} + +async function fetchAlbums(token: string): Promise { + const res = await fetch(`${getApiBaseUrl()}/my/memories`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }).catch(() => null); + if (!res || !res.ok) return []; + return (await res.json()) as Album[]; +} + +export default async function MemoriesPage() { + const token = await getMemberToken(); + if (!token) return null; + + const albums = await fetchAlbums(token); + + return ( +
+
+

Memories

+ ← Dashboard +
+ + {albums.length === 0 ? ( +
+

No photo albums yet.

+

Your chapter admin will share event photos and memories here.

+
+ ) : ( +
+ {albums.map((a) => ( + +
+ {a.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {a.title} + ) : ( +
🖼️
+ )} +
+
+

{a.title}

+

{a.itemCount} photo{a.itemCount !== 1 ? 's' : ''}

+
+ + ))} +
+ )} +
+ ); +}