feat: member portal Sprint 11 — Memories / Gallery
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<Response> {
|
||||
const { id } = await params;
|
||||
const res = await memberApiFetch(`/my/memories/${id}`);
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const res = await memberApiFetch('/my/memories');
|
||||
const body = await res.json();
|
||||
return jsonResponse(body, res.status);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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<AlbumDetail | null> {
|
||||
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 (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{album.title}</h1>
|
||||
{album.description && <p className="text-sm text-gray-500 mt-0.5">{album.description}</p>}
|
||||
</div>
|
||||
<Link href="/my/memories" className="text-sm text-indigo-600 hover:underline shrink-0">← Albums</Link>
|
||||
</div>
|
||||
|
||||
{album.items.length === 0 ? (
|
||||
<p className="text-center py-16 text-gray-400 text-sm">This album has no photos yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{album.items.map((item) => (
|
||||
<figure key={item.id} className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div className="aspect-square bg-gray-100 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.mediaUrl} alt={item.caption ?? ''} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
{item.caption && (
|
||||
<figcaption className="text-xs text-gray-500 px-2 py-1.5 truncate">{item.caption}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Album[]> {
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Memories</h1>
|
||||
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
||||
</div>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<p className="text-sm">No photo albums yet.</p>
|
||||
<p className="text-xs mt-1">Your chapter admin will share event photos and memories here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{albums.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/my/memories/${a.id}`}
|
||||
className="group bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-gray-100 overflow-hidden">
|
||||
{a.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.coverUrl}
|
||||
alt={a.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-3xl text-gray-300">🖼️</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">{a.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{a.itemCount} photo{a.itemCount !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user