feat: member portal Sprint 2 — digest history page and restore Digest/DigestConfig schema models
- Restore Digest + DigestConfig models to schema.prisma (wiped by prior git reset) - Add Tenant.digestConfig / Tenant.digests relations - GET /my/digest API endpoint (last 20 digests, truncated summary) - BFF route /api/my/digest - /my/digest page: digest cards with date, message count, summary preview - Add Digest nav item to MemberShell sidebar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,8 @@ model Tenant {
|
|||||||
auditEvents AuditEvent[]
|
auditEvents AuditEvent[]
|
||||||
rules TenantRule[]
|
rules TenantRule[]
|
||||||
groupAccesses GroupAccess[]
|
groupAccesses GroupAccess[]
|
||||||
|
digestConfig DigestConfig?
|
||||||
|
digests Digest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AdminRole {
|
enum AdminRole {
|
||||||
@@ -495,3 +497,34 @@ model TenantRule {
|
|||||||
@@index([tenantId, isActive])
|
@@index([tenantId, isActive])
|
||||||
@@index([tenantId, matchType])
|
@@index([tenantId, matchType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Digest
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model DigestConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @unique
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
targetGroupJid String
|
||||||
|
targetAccountId String
|
||||||
|
scheduleHour Int @default(20)
|
||||||
|
scheduleMinute Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastSentAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Digest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
content String
|
||||||
|
messageIds String[]
|
||||||
|
digestDate DateTime
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([tenantId, digestDate])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export class MyController {
|
|||||||
return this.service.getDashboard(member.sub, member.tenantId);
|
return this.service.getDashboard(member.sub, member.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('digest')
|
||||||
|
digests(@CurrentMember() member: MemberJwtPayload) {
|
||||||
|
return this.service.getDigests(member.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
profile(@CurrentMember() member: MemberJwtPayload) {
|
profile(@CurrentMember() member: MemberJwtPayload) {
|
||||||
return this.service.getProfile(member.sub, member.tenantId);
|
return this.service.getProfile(member.sub, member.tenantId);
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export class MyService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDigests(tenantId: string) {
|
||||||
|
const digests = await this.prisma.digest.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { digestDate: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: { id: true, digestDate: true, content: true, messageIds: true, sentAt: true },
|
||||||
|
});
|
||||||
|
return digests.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
digestDate: d.digestDate.toISOString(),
|
||||||
|
summary: d.content.slice(0, 300),
|
||||||
|
messageCount: (d.messageIds as string[]).length,
|
||||||
|
sentAt: d.sentAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async getDashboard(userId: string, tenantId: string) {
|
async getDashboard(userId: string, tenantId: string) {
|
||||||
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
const user = await this.prisma.towerUser.findFirst({ where: { id: userId, tenantId } });
|
||||||
if (!user) throw new NotFoundException('User not found');
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|||||||
@@ -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/digest');
|
||||||
|
const body = await res.json();
|
||||||
|
return jsonResponse(body, res.status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface DigestItem {
|
||||||
|
id: string;
|
||||||
|
digestDate: string;
|
||||||
|
summary: string;
|
||||||
|
messageCount: number;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDigests(token: string): Promise<DigestItem[]> {
|
||||||
|
const res = await fetch(`${getApiBaseUrl()}/my/digest`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!res || !res.ok) return [];
|
||||||
|
return (await res.json()) as DigestItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DigestPage() {
|
||||||
|
const token = await getMemberToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const digests = await fetchDigests(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">Digest history</h1>
|
||||||
|
<Link href="/my" className="text-sm text-indigo-600 hover:underline">← Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{digests.length === 0 && (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
<p className="text-sm">No digests sent yet.</p>
|
||||||
|
<p className="text-xs mt-1">Your chapter admin generates these periodically.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{digests.map((d) => (
|
||||||
|
<div key={d.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
{new Date(d.digestDate).toLocaleDateString('en-IN', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{d.messageCount} message{d.messageCount !== 1 ? 's' : ''} · sent {new Date(d.sentAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs bg-indigo-50 text-indigo-600 rounded-full px-3 py-1 font-medium">
|
||||||
|
Digest
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
|
||||||
|
{d.summary}
|
||||||
|
{d.summary.length >= 300 && <span className="text-gray-400">…</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: '/my', label: 'Dashboard' },
|
{ href: '/my', label: 'Dashboard' },
|
||||||
|
{ href: '/my/digest', label: 'Digest' },
|
||||||
{ href: '/my/groups', label: 'My Groups' },
|
{ href: '/my/groups', label: 'My Groups' },
|
||||||
{ href: '/my/settings', label: 'Settings' },
|
{ href: '/my/settings', label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user