diff --git a/apps/api/.env.example b/apps/api/.env.example index 0f9f0e1..b9149b1 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,3 +3,8 @@ JWT_SECRET="your-random-secret-here" PORT=3000 NODE_ENV=development JWT_REFRESH_SECRET=your-refresh-secret-here + +# Add these lines to apps/api/.env.example +MSG91_AUTH_KEY=your_msg91_auth_key +MSG91_SENDER_ID=VAASTIO +MSG91_TEMPLATE_ID=your_template_id \ No newline at end of file diff --git a/apps/api/prisma/migrations/20260423082324_add_announcements/migration.sql b/apps/api/prisma/migrations/20260423082324_add_announcements/migration.sql new file mode 100644 index 0000000..9e4427b --- /dev/null +++ b/apps/api/prisma/migrations/20260423082324_add_announcements/migration.sql @@ -0,0 +1,42 @@ +-- CreateEnum +CREATE TYPE "AnnouncementCategory" AS ENUM ('GENERAL', 'MAINTENANCE', 'MEETING', 'EMERGENCY', 'CELEBRATION'); + +-- CreateTable +CREATE TABLE "announcements" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "category" "AnnouncementCategory" NOT NULL DEFAULT 'GENERAL', + "isPinned" BOOLEAN NOT NULL DEFAULT false, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "announcements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "announcement_images" ( + "id" TEXT NOT NULL, + "announcementId" TEXT NOT NULL, + "imageUrl" TEXT NOT NULL, + + CONSTRAINT "announcement_images_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "announcements_orgId_idx" ON "announcements"("orgId"); + +-- CreateIndex +CREATE INDEX "announcements_orgId_isPinned_idx" ON "announcements"("orgId", "isPinned"); + +-- AddForeignKey +ALTER TABLE "announcements" ADD CONSTRAINT "announcements_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcements" ADD CONSTRAINT "announcements_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcement_images" ADD CONSTRAINT "announcement_images_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "announcements"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b9187e7..100eec7 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { deviceTokens DeviceToken[] complaintsRaised Complaint[] @relation("ComplaintRaiser") complaintsResolved Complaint[] @relation("ComplaintResolver") + announcementsCreated Announcement[] @relation("AnnouncementCreator") @@map("users") } @@ -73,6 +74,7 @@ model Organization { auditLogs AuditLog[] invitations Invitation[] complaints Complaint[] + announcements Announcement[] @@map("organizations") } @@ -354,6 +356,37 @@ model ComplaintImage { @@map("complaint_images") } +model Announcement { + id String @id @default(uuid()) + orgId String + title String + body String + category AnnouncementCategory @default(GENERAL) + isPinned Boolean @default(false) + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + org Organization @relation(fields: [orgId], references: [id]) + creator User @relation("AnnouncementCreator", fields: [createdBy], references: [id]) + images AnnouncementImage[] + + @@index([orgId]) + @@index([orgId, isPinned]) + @@map("announcements") +} + +model AnnouncementImage { + id String @id @default(uuid()) + announcementId String + imageUrl String + + announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade) + + @@map("announcement_images") +} + enum ComplaintCategory { WATER_SUPPLY ELECTRICITY @@ -385,4 +418,12 @@ enum ComplaintStatus { OPEN RESOLVED REJECTED +} + +enum AnnouncementCategory { + GENERAL + MAINTENANCE + MEETING + EMERGENCY + CELEBRATION } \ No newline at end of file diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index d9180e3..c502399 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -87,6 +87,12 @@ async function main() { { name: 'complaint.resolve_any', module: 'complaints', description: 'Resolve any complaint' }, { name: 'complaint.reject', module: 'complaints', description: 'Reject a complaint with reason' }, + // Announcement + { name: 'announcement.create', module: 'announcements', description: 'Create announcements' }, + { name: 'announcement.delete', module: 'announcements', description: 'Delete announcements' }, + { name: 'announcement.pin', module: 'announcements', description: 'Pin announcements' }, + { name: 'announcement.view', module: 'announcements', description: 'View announcements' }, + // Units { name: 'unit.assign', module: 'units', description: 'Assign ownership and occupancy to units' }, { name: 'unit.view_all', module: 'units', description: 'View all units and assignments in society' }, @@ -117,7 +123,11 @@ async function main() { 'visitor.view_live', 'visitor.view_emergency', 'emergency.declare', 'emergency.view', 'role.create', 'role.assign', 'role.view', - 'unit.assign', 'unit.view_all' + 'unit.assign', 'unit.view_all', + 'announcement.create', + 'announcement.delete', + 'announcement.pin', + 'announcement.view' ], Admin: [ @@ -135,7 +145,11 @@ async function main() { 'asset.create', 'asset.book', 'asset.view', 'asset.manage_booking', 'role.create', 'role.assign', 'role.view', 'complaint.view_all', 'complaint.resolve_any', 'complaint.reject', - 'unit.assign', 'unit.view_all' + 'unit.assign', 'unit.view_all', + 'announcement.create', + 'announcement.delete', + 'announcement.pin', + 'announcement.view' ], Resident: [ @@ -148,7 +162,7 @@ async function main() { 'emergency.declare', 'emergency.view', 'asset.book', 'asset.view', 'co_resident.invite', - 'unit.view_own' + 'unit.view_own', 'announcement.view' ], 'Co-resident': [ @@ -160,13 +174,15 @@ async function main() { 'poll.vote', 'poll.view', 'emergency.declare', 'emergency.view', 'asset.book', 'asset.view', - 'unit.view_own' + 'unit.view_own', + 'announcement.view' ], Gatekeeper: [ 'society.view', 'visitor.log', 'visitor.view_live', - 'emergency.declare', 'emergency.view' + 'emergency.declare', 'emergency.view', + 'announcement.view' ] } @@ -202,11 +218,15 @@ async function main() { where: { name: permName } }) if (permission) { - await prisma.rolePermission.create({ - data: { - roleId: role.id, - permissionId: permission.id - } + await prisma.rolePermission.upsert({ + where: { + roleId_permissionId: { + roleId: role.id, + permissionId: permission.id + } + }, + update: {}, + create: { roleId: role.id, permissionId: permission.id } }) } else { console.warn(`Permission not found: ${permName}`) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 48e84d8..1b29bd4 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import invitationsRouter from './routes/invitations' import membersRouter from './routes/members' import deviceTokensRouter from './routes/deviceTokens' import complaintsRouter from './routes/complaints' +import announcementsRouter from './routes/announcements' import unitsRouter from './routes/units' import { enforceTenantContext } from './middleware/tenantContext' import { errorHandler } from './middleware/error' @@ -27,6 +28,7 @@ app.use('/api/societies', invitationsRouter) app.use('/api/societies', membersRouter) app.use('/api/auth', deviceTokensRouter) app.use('/api/societies', complaintsRouter) +app.use('/api/societies', announcementsRouter) app.use('/api/societies', unitsRouter) // Initialize notification dispatcher diff --git a/apps/api/src/notifications/rules.ts b/apps/api/src/notifications/rules.ts index 8d7e659..dc962a6 100644 --- a/apps/api/src/notifications/rules.ts +++ b/apps/api/src/notifications/rules.ts @@ -93,7 +93,7 @@ export const notificationRules: Partial> = { payload: (d) => ({ title: d.societyName, body: d.title, - priority: 'high' as const, + priority: d.category === 'EMERGENCY' ? 'high' : 'default', data: { screen: 'Announcements', orgId: d.orgId, diff --git a/apps/api/src/routes/announcements.ts b/apps/api/src/routes/announcements.ts new file mode 100644 index 0000000..8b3c4a8 --- /dev/null +++ b/apps/api/src/routes/announcements.ts @@ -0,0 +1,280 @@ +import { Router, Response } from 'express' +import { prisma } from '../lib/prisma' +import { authenticate, AuthRequest } from '../middleware/auth' +import { requirePermission } from '../middleware/permission' +import { enforceTenantContext } from '../middleware/tenantContext' +import { sendSuccess, sendError, sendNotFound } from '../utils/response' +import { uploadMultipleImages } from '../utils/cloudinary' +import { appEvents, Events } from '../events/emitter' + +const router = Router() + +const VALID_CATEGORIES = ['GENERAL', 'MAINTENANCE', 'MEETING', 'EMERGENCY', 'CELEBRATION'] + +// ───────────────────────────────────────────── +// POST /api/societies/:id/announcements +// Create a new announcement +// ───────────────────────────────────────────── +router.post( + '/:id/announcements', + authenticate, + enforceTenantContext, + requirePermission('announcement.create'), + async (req: AuthRequest, res: Response) => { + try { + const { id: orgId } = req.params + const { title, body, category, images } = req.body + + if (!title || !title.trim()) { + return sendError(res, 'missing_field', 400, { field: 'title' }) + } + if (!body || !body.trim()) { + return sendError(res, 'missing_field', 400, { field: 'body' }) + } + if (category && !VALID_CATEGORIES.includes(category)) { + return sendError(res, 'invalid_category', 400) + } + if (images && images.length > 5) { + return sendError(res, 'too_many_images', 400, { + message: 'Maximum 5 images allowed' + }) + } + + // Upload images to Cloudinary if provided + let imageUrls: string[] = [] + if (images && images.length > 0) { + try { + imageUrls = await uploadMultipleImages(images) + } catch (uploadError) { + console.error('Cloudinary upload error:', uploadError) + return sendError(res, 'image_upload_failed', 400) + } + } + + const announcement = await prisma.announcement.create({ + data: { + orgId, + createdBy: req.user!.userId, + title: title.trim(), + body: body.trim(), + category: category ?? 'GENERAL', + images: { + create: imageUrls.map(url => ({ imageUrl: url })) + } + }, + include: { + images: true, + creator: { include: { person: true } } + } + }) + + const org = await prisma.organization.findUnique({ where: { id: orgId } }) + + appEvents.emit(Events.ANNOUNCEMENT_CREATED, { + orgId, + announcementId: announcement.id, + title: announcement.title, + category: announcement.category, + createdByUserId: req.user!.userId, + societyName: org?.name ?? '', + }) + + return sendSuccess(res, { + id: announcement.id, + title: announcement.title, + body: announcement.body, + category: announcement.category, + isPinned: announcement.isPinned, + images: announcement.images.map(img => ({ id: img.id, imageUrl: img.imageUrl })), + createdBy: { name: announcement.creator.person?.fullName ?? 'Unknown' }, + createdAt: announcement.createdAt, + }, 201) + + } catch (error) { + console.error('POST /announcements error:', error) + return sendError(res, 'server_error', 500) + } + } +) + +// ───────────────────────────────────────────── +// GET /api/societies/:id/announcements +// List announcements — pinned first, then newest +// ───────────────────────────────────────────── +router.get( + '/:id/announcements', + authenticate, + enforceTenantContext, + requirePermission('announcement.view'), + async (req: AuthRequest, res: Response) => { + try { + const { id: orgId } = req.params + const { category } = req.query + + if (category && !VALID_CATEGORIES.includes(category as string)) { + return sendError(res, 'invalid_category', 400) + } + + const where: any = { orgId, deletedAt: null } + if (category) where.category = category + + const announcements = await prisma.announcement.findMany({ + where, + orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }], + include: { + images: { select: { id: true, imageUrl: true } }, + creator: { include: { person: true } } + } + }) + + return sendSuccess(res, { + announcements: announcements.map(a => ({ + id: a.id, + title: a.title, + body: a.body, + category: a.category, + isPinned: a.isPinned, + images: a.images, + createdBy: { name: a.creator.person?.fullName ?? 'Unknown' }, + createdAt: a.createdAt, + })) + }) + + } catch (error) { + console.error('GET /announcements error:', error) + return sendError(res, 'server_error', 500) + } + } +) + +// ───────────────────────────────────────────── +// GET /api/societies/:id/announcements/:announcementId +// Announcement detail +// ───────────────────────────────────────────── +router.get( + '/:id/announcements/:announcementId', + authenticate, + enforceTenantContext, + requirePermission('announcement.view'), + async (req: AuthRequest, res: Response) => { + try { + const { id: orgId, announcementId } = req.params + + const announcement = await prisma.announcement.findFirst({ + where: { id: announcementId, orgId, deletedAt: null }, + include: { + images: true, + creator: { include: { person: true } } + } + }) + + if (!announcement) { + return sendNotFound(res, 'announcement_not_found') + } + + return sendSuccess(res, { + id: announcement.id, + title: announcement.title, + body: announcement.body, + category: announcement.category, + isPinned: announcement.isPinned, + images: announcement.images.map(img => ({ id: img.id, imageUrl: img.imageUrl })), + createdBy: { + name: announcement.creator.person?.fullName ?? 'Unknown', + phone: announcement.creator.phone, + }, + createdAt: announcement.createdAt, + }) + + } catch (error) { + console.error('GET /announcements/:id error:', error) + return sendError(res, 'server_error', 500) + } + } +) + +// ───────────────────────────────────────────── +// PATCH /api/societies/:id/announcements/:announcementId/pin +// Toggle pin — max 3 pinned at a time +// ───────────────────────────────────────────── +router.patch( + '/:id/announcements/:announcementId/pin', + authenticate, + enforceTenantContext, + requirePermission('announcement.pin'), + async (req: AuthRequest, res: Response) => { + try { + const { id: orgId, announcementId } = req.params + + const announcement = await prisma.announcement.findFirst({ + where: { id: announcementId, orgId, deletedAt: null } + }) + + if (!announcement) { + return sendNotFound(res, 'announcement_not_found') + } + + // Enforce max 3 pinned when pinning + if (!announcement.isPinned) { + const pinnedCount = await prisma.announcement.count({ + where: { orgId, isPinned: true, deletedAt: null } + }) + if (pinnedCount >= 3) { + return sendError(res, 'max_pinned_reached', 400, { + message: 'Maximum 3 announcements can be pinned. Unpin one first.' + }) + } + } + + const updated = await prisma.announcement.update({ + where: { id: announcementId }, + data: { isPinned: !announcement.isPinned } + }) + + return sendSuccess(res, { + message: 'pin_updated', + isPinned: updated.isPinned, + }) + + } catch (error) { + console.error('PATCH /announcements/:id/pin error:', error) + return sendError(res, 'server_error', 500) + } + } +) + +// ───────────────────────────────────────────── +// DELETE /api/societies/:id/announcements/:announcementId +// Hard delete — cascade removes images +// ───────────────────────────────────────────── +router.delete( + '/:id/announcements/:announcementId', + authenticate, + enforceTenantContext, + requirePermission('announcement.delete'), + async (req: AuthRequest, res: Response) => { + try { + const { id: orgId, announcementId } = req.params + + const announcement = await prisma.announcement.findFirst({ + where: { id: announcementId, orgId, deletedAt: null } + }) + + if (!announcement) { + return sendNotFound(res, 'announcement_not_found') + } + + await prisma.announcement.delete({ + where: { id: announcementId } + }) + + return sendSuccess(res, { message: 'announcement_deleted' }) + + } catch (error) { + console.error('DELETE /announcements/:id error:', error) + return sendError(res, 'server_error', 500) + } + } +) + +export default router diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 6072a6c..d3d90a7 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -53,10 +53,7 @@ router.post('/request-otp', otpRateLimit, async (req: AuthRequest, res: Response }) // 5. send SMS - const smsResult = await sendOtp(normalizedPhone, otp) - if (!smsResult.success) { - return sendError(res, 'sms_failed', 500) - } + await sendOtp(normalizedPhone, otp) // 6. respond — never return OTP in response return sendSuccess(res, { diff --git a/apps/api/src/utils/sms.ts b/apps/api/src/utils/sms.ts index 09729bd..a080e5c 100644 --- a/apps/api/src/utils/sms.ts +++ b/apps/api/src/utils/sms.ts @@ -1,23 +1,59 @@ -interface SmsResult { - success: boolean - error?: string -} +// ───────────────────────────────────────────── +// SMS Utility +// Development: logs OTP to console +// Production: sends real SMS via MSG91 +// ───────────────────────────────────────────── + +const MSG91_AUTH_KEY = process.env.MSG91_AUTH_KEY +const MSG91_SENDER_ID = process.env.MSG91_SENDER_ID || 'VAASTIO' +const MSG91_TEMPLATE_ID = process.env.MSG91_TEMPLATE_ID export const sendOtp = async ( phone: string, - message: string -): Promise => { - if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { - console.log(`\n--------------------------`) - console.log(`SMS for ${phone}: ${message}`) - console.log(`--------------------------\n`) - return { success: true } + otp: string +): Promise => { + if (process.env.NODE_ENV !== 'production') { + // Development and test — never use real SMS credits + console.log('--------------------------') + console.log(`SMS for ${phone}: ${otp}`) + console.log('--------------------------') + return } - // Production: wire in SMS provider here - // Example: MSG91, Fast2SMS, Twilio - // const response = await msg91.send(phone, otp) - // return { success: true } + // Production — send real SMS via MSG91 + try { + const mobile = phone.replace('+', '') + + const payload = { + template_id: MSG91_TEMPLATE_ID, + short_url: '0', + realTimeResponse: '1', + recipients: [ + { + mobiles: mobile, + OTP: otp + } + ] + } + + const response = await fetch('https://control.msg91.com/api/v5/flow/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'authkey': MSG91_AUTH_KEY || '' + }, + body: JSON.stringify(payload) + }) - return { success: false, error: 'sms_provider_not_configured' } + const data = await response.json() + + if (!response.ok || data.type === 'error') { + console.error('MSG91 error:', data) + } + + } catch (error) { + // Never throw — SMS failure must not crash main flow + console.error('SMS send failed:', error) + } } \ No newline at end of file diff --git a/apps/api/tests/announcements.test.ts b/apps/api/tests/announcements.test.ts new file mode 100644 index 0000000..890abcc --- /dev/null +++ b/apps/api/tests/announcements.test.ts @@ -0,0 +1,280 @@ +import request from 'supertest' +import app from '../src/app' +import { getTokens, getSocietyId } from './setup' +import { prisma } from '../src/lib/prisma' + +describe('Announcements', () => { + let builderToken: string + let residentToken: string + let societyId: string + let announcementId: string + + beforeAll(async () => { + const tokens = await getTokens() + builderToken = tokens['Builder'] + residentToken = tokens['Resident'] + societyId = await getSocietyId() + + // Clean up any announcements from previous runs + await prisma.announcement.deleteMany({ where: { orgId: societyId } }) + }) + + // ───────────────────────────────────────────── + // POST /societies/:id/announcements + // ───────────────────────────────────────────── + describe('POST /announcements', () => { + it('returns 401 with no token', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .send({}) + expect(res.status).toBe(401) + }) + + it('returns 403 for resident', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${residentToken}`) + .send({ title: 'Test', body: 'Test body' }) + expect(res.status).toBe(403) + expect(res.body.error).toBe('insufficient_permissions') + }) + + it('creates announcement successfully (builder)', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Water Supply Shutdown', body: 'Water will be cut on Sunday 9am–1pm' }) + expect(res.status).toBe(201) + expect(res.body.data.title).toBe('Water Supply Shutdown') + expect(res.body.data.category).toBe('GENERAL') + expect(res.body.data.isPinned).toBe(false) + expect(res.body.data.createdBy.name).toBeDefined() + announcementId = res.body.data.id + }) + + it('creates with category MAINTENANCE', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Lift Maintenance', body: 'Lift A will be serviced on Monday', category: 'MAINTENANCE' }) + expect(res.status).toBe(201) + expect(res.body.data.category).toBe('MAINTENANCE') + }) + + it('returns 400 for missing title', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ body: 'Some body text' }) + expect(res.status).toBe(400) + expect(res.body.error).toBe('missing_field') + }) + + it('returns 400 for missing body', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Some title' }) + expect(res.status).toBe(400) + expect(res.body.error).toBe('missing_field') + }) + + it('returns 400 for invalid category', async () => { + const res = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Test', body: 'Test body', category: 'INVALID' }) + expect(res.status).toBe(400) + expect(res.body.error).toBe('invalid_category') + }) + }) + + // ───────────────────────────────────────────── + // GET /societies/:id/announcements + // ───────────────────────────────────────────── + describe('GET /announcements', () => { + it('returns 401 with no token', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements`) + expect(res.status).toBe(401) + }) + + it('returns 200 with announcements list', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(Array.isArray(res.body.data.announcements)).toBe(true) + }) + + it('returns announcements newest first within non-pinned', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + const unpinned = res.body.data.announcements.filter((a: any) => !a.isPinned) + for (let i = 1; i < unpinned.length; i++) { + expect(new Date(unpinned[i - 1].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(unpinned[i].createdAt).getTime() + ) + } + }) + + it('returns pinned announcements first', async () => { + // Pin the first announcement + await request(app) + .patch(`/api/societies/${societyId}/announcements/${announcementId}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + + const res = await request(app) + .get(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.announcements[0].isPinned).toBe(true) + }) + + it('filters by category correctly', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements?category=MAINTENANCE`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.announcements.every((a: any) => a.category === 'MAINTENANCE')).toBe(true) + }) + }) + + // ───────────────────────────────────────────── + // GET /societies/:id/announcements/:id + // ───────────────────────────────────────────── + describe('GET /announcements/:id', () => { + it('returns 401 with no token', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements/${announcementId}`) + expect(res.status).toBe(401) + }) + + it('returns 404 for non-existent', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements/00000000-0000-0000-0000-000000000000`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(404) + expect(res.body.error).toBe('announcement_not_found') + }) + + it('returns full announcement details', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements/${announcementId}`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.id).toBe(announcementId) + expect(res.body.data.title).toBe('Water Supply Shutdown') + expect(res.body.data.body).toBeDefined() + expect(res.body.data.category).toBeDefined() + expect(res.body.data.createdBy.name).toBeDefined() + expect(res.body.data.createdBy.phone).toBeDefined() + expect(Array.isArray(res.body.data.images)).toBe(true) + }) + }) + + // ───────────────────────────────────────────── + // PATCH /societies/:id/announcements/:id/pin + // ───────────────────────────────────────────── + describe('PATCH /announcements/:id/pin', () => { + it('returns 401 with no token', async () => { + const res = await request(app) + .patch(`/api/societies/${societyId}/announcements/${announcementId}/pin`) + expect(res.status).toBe(401) + }) + + it('returns 403 for resident', async () => { + const res = await request(app) + .patch(`/api/societies/${societyId}/announcements/${announcementId}/pin`) + .set('Authorization', `Bearer ${residentToken}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('insufficient_permissions') + }) + + it('unpins an announcement successfully', async () => { + // announcementId is currently pinned from the GET test above + const res = await request(app) + .patch(`/api/societies/${societyId}/announcements/${announcementId}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.message).toBe('pin_updated') + expect(res.body.data.isPinned).toBe(false) + }) + + it('pins an announcement successfully', async () => { + const res = await request(app) + .patch(`/api/societies/${societyId}/announcements/${announcementId}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.isPinned).toBe(true) + }) + + it('returns 400 when trying to pin 4th announcement', async () => { + // Create 2 more announcements and pin them (announcementId is already pinned = 1 pinned) + const second = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Pin Test 2', body: 'Body 2' }) + await request(app) + .patch(`/api/societies/${societyId}/announcements/${second.body.data.id}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + + const third = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Pin Test 3', body: 'Body 3' }) + await request(app) + .patch(`/api/societies/${societyId}/announcements/${third.body.data.id}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + + // Now 3 are pinned — try to pin a 4th + const fourth = await request(app) + .post(`/api/societies/${societyId}/announcements`) + .set('Authorization', `Bearer ${builderToken}`) + .send({ title: 'Pin Test 4', body: 'Body 4' }) + + const res = await request(app) + .patch(`/api/societies/${societyId}/announcements/${fourth.body.data.id}/pin`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(400) + expect(res.body.error).toBe('max_pinned_reached') + }) + }) + + // ───────────────────────────────────────────── + // DELETE /societies/:id/announcements/:id + // ───────────────────────────────────────────── + describe('DELETE /announcements/:id', () => { + it('returns 401 with no token', async () => { + const res = await request(app) + .delete(`/api/societies/${societyId}/announcements/${announcementId}`) + expect(res.status).toBe(401) + }) + + it('returns 403 for resident', async () => { + const res = await request(app) + .delete(`/api/societies/${societyId}/announcements/${announcementId}`) + .set('Authorization', `Bearer ${residentToken}`) + expect(res.status).toBe(403) + expect(res.body.error).toBe('insufficient_permissions') + }) + + it('deletes announcement successfully', async () => { + const res = await request(app) + .delete(`/api/societies/${societyId}/announcements/${announcementId}`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(200) + expect(res.body.data.message).toBe('announcement_deleted') + }) + + it('returns 404 after deletion', async () => { + const res = await request(app) + .get(`/api/societies/${societyId}/announcements/${announcementId}`) + .set('Authorization', `Bearer ${builderToken}`) + expect(res.status).toBe(404) + expect(res.body.error).toBe('announcement_not_found') + }) + }) +}) diff --git a/apps/mobile/assets/adaptive-icon.png b/apps/mobile/assets/adaptive-icon.png index 03d6f6b..62103d8 100644 Binary files a/apps/mobile/assets/adaptive-icon.png and b/apps/mobile/assets/adaptive-icon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png index 30f98f4..e52eac4 100644 Binary files a/apps/mobile/assets/icon.png and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx index 0ab72ae..e15d958 100644 --- a/apps/mobile/src/navigation/AppNavigator.tsx +++ b/apps/mobile/src/navigation/AppNavigator.tsx @@ -15,6 +15,9 @@ import { UnitInventoryScreen } from '../screens/units/UnitInventoryScreen' import { UnitDetailScreen } from '../screens/units/UnitDetailScreen' import { AssignUnitScreen } from '../screens/units/AssignUnitScreen' import { MyHomeScreen } from '../screens/units/MyHomeScreen' +import { AnnouncementsListScreen } from '../screens/announcements/AnnouncementsListScreen' +import { CreateAnnouncementScreen } from '../screens/announcements/CreateAnnouncementScreen' +import { AnnouncementDetailScreen } from '../screens/announcements/AnnouncementDetailScreen' import { Colors } from '../constants/colors' export type AppStackParamList = { @@ -39,6 +42,9 @@ export type AppStackParamList = { prefillUnitName?: string } MyHome: { societyId: string; memberId: string } + AnnouncementsList: { societyId: string } + CreateAnnouncement: { societyId: string } + AnnouncementDetail: { societyId: string; announcementId: string } } const Stack = createNativeStackNavigator() @@ -79,6 +85,9 @@ export function AppNavigator({ initialSocietyId }: AppNavigatorProps) { ({ title: route.params.unitName })} /> + + + ) } diff --git a/apps/mobile/src/screens/announcements/AnnouncementDetailScreen.tsx b/apps/mobile/src/screens/announcements/AnnouncementDetailScreen.tsx new file mode 100644 index 0000000..82050d6 --- /dev/null +++ b/apps/mobile/src/screens/announcements/AnnouncementDetailScreen.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { + View, + Text, + ScrollView, + Image, + Pressable, + StyleSheet, + ActivityIndicator, +} from 'react-native' +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { ScreenWrapper } from '../../components/ScreenWrapper' +import { LoadingSpinner } from '../../components/LoadingSpinner' +import { Toast } from '../../components/Toast' +import { Button } from '../../components/Button' +import { ConfirmSheet } from '../../components/ConfirmSheet' +import { AppStackParamList } from '../../navigation/AppNavigator' +import { useAuth } from '../../hooks/useAuth' +import { + getAnnouncement, + pinAnnouncement, + deleteAnnouncement, + Announcement, + AnnouncementCategory, +} from '../../services/announcements' +import { getApiErrorCode } from '../../services/api' +import { getErrorMessage } from '../../utils/errorMessages' +import { Colors } from '../../constants/colors' +import { Spacing } from '../../constants/spacing' + +type Props = NativeStackScreenProps + +const CATEGORY_ICON: Record = { + GENERAL: '📢', + MAINTENANCE: '🔧', + MEETING: '👥', + EMERGENCY: '🚨', + CELEBRATION: '🎉', +} + +const CATEGORY_COLORS: Record = { + GENERAL: { bg: '#f3f4f6', text: '#6b7280' }, + MAINTENANCE: { bg: '#fff7ed', text: '#ea580c' }, + MEETING: { bg: '#eff6ff', text: '#2563eb' }, + EMERGENCY: { bg: '#fef2f2', text: '#dc2626' }, + CELEBRATION: { bg: '#faf5ff', text: '#9333ea' }, +} + +export function AnnouncementDetailScreen({ route, navigation }: Props) { + const { societyId, announcementId } = route.params + const { permissions } = useAuth() + + const canPin = permissions.includes('announcement.pin') + const canDelete = permissions.includes('announcement.delete') + + const [announcement, setAnnouncement] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isPinning, setIsPinning] = useState(false) + const [showDeleteSheet, setShowDeleteSheet] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' | 'info' } | null>(null) + + const load = useCallback(async () => { + try { + setIsLoading(true) + const data = await getAnnouncement(societyId, announcementId) + setAnnouncement(data) + navigation.setOptions({ title: data.title }) + } catch { + setToast({ message: 'Could not load announcement.', type: 'error' }) + } finally { + setIsLoading(false) + } + }, [societyId, announcementId, navigation]) + + useEffect(() => { + load() + }, [load]) + + async function handlePin() { + if (!announcement) return + setIsPinning(true) + try { + const result = await pinAnnouncement(societyId, announcementId) + setAnnouncement((prev) => prev ? { ...prev, isPinned: result.isPinned } : prev) + } catch (e) { + const code = getApiErrorCode(e) + if (code === 'max_pinned_reached') { + setToast({ message: 'Maximum 3 announcements can be pinned. Unpin one first.', type: 'error' }) + } else { + setToast({ message: getErrorMessage(code), type: 'error' }) + } + } finally { + setIsPinning(false) + } + } + + async function handleDeleteConfirm() { + setIsDeleting(true) + try { + await deleteAnnouncement(societyId, announcementId) + setShowDeleteSheet(false) + navigation.goBack() + } catch (e) { + const code = getApiErrorCode(e) + setShowDeleteSheet(false) + setToast({ message: getErrorMessage(code), type: 'error' }) + } finally { + setIsDeleting(false) + } + } + + if (isLoading) return + if (!announcement) return null + + const colors = CATEGORY_COLORS[announcement.category] + + return ( + + + + {/* Badges row */} + + + {CATEGORY_ICON[announcement.category]} + + {announcement.category.charAt(0) + announcement.category.slice(1).toLowerCase()} + + + {announcement.isPinned ? ( + + 📌 Pinned + + ) : null} + + + {/* Title */} + {announcement.title} + + {/* Body */} + {announcement.body} + + {/* Images */} + {announcement.images.length > 0 ? ( + + {announcement.images.map((img) => ( + + ))} + + ) : null} + + {/* Footer */} + + + Posted by {announcement.createdBy.name} + + {formatDate(announcement.createdAt)} + + + {/* Admin actions */} + {(canPin || canDelete) ? ( + + {canPin ? ( + [styles.actionBtn, styles.actionBtnOutline, pressed && styles.actionBtnPressed]} + > + {isPinning + ? + : + {announcement.isPinned ? 'Unpin' : 'Pin'} + + } + + ) : null} + {canDelete ? ( + setShowDeleteSheet(true)} + style={({ pressed }) => [styles.actionBtn, styles.actionBtnDestructive, pressed && styles.actionBtnPressed]} + > + Delete + + ) : null} + + ) : null} + + + + setShowDeleteSheet(false)} + /> + + {toast ? ( + setToast(null)} + /> + ) : null} + + ) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('en-IN', { + day: 'numeric', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + wrapper: { backgroundColor: Colors.background }, + + content: { + padding: Spacing.screenPadding, + paddingBottom: 40, + gap: 20, + }, + + badgeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + }, + categoryBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + }, + categoryIcon: { fontSize: 14 }, + categoryText: { fontSize: 13, fontWeight: '600' }, + pinnedBadge: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + backgroundColor: '#fefce8', + }, + pinnedText: { fontSize: 13, fontWeight: '600', color: '#92400e' }, + + title: { + fontSize: 22, + fontWeight: '700', + color: Colors.text, + lineHeight: 30, + }, + + body: { + fontSize: 16, + color: Colors.text, + lineHeight: 26, + }, + + imagesRow: { + gap: 10, + paddingVertical: 4, + }, + image: { + width: 220, + height: 160, + borderRadius: 12, + backgroundColor: Colors.border, + }, + + footer: { + gap: 4, + paddingTop: 8, + borderTopWidth: 1, + borderTopColor: Colors.border, + }, + footerText: { fontSize: 13, color: Colors.subtle }, + footerName: { fontWeight: '600', color: Colors.text }, + footerDate: { fontSize: 13, color: Colors.subtle }, + + actions: { + flexDirection: 'row', + gap: 10, + }, + actionBtn: { + flex: 1, + height: 46, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + actionBtnPressed: { opacity: 0.7 }, + actionBtnOutline: { + borderWidth: 1.5, + borderColor: Colors.primary, + backgroundColor: Colors.surface, + }, + actionBtnOutlineText: { fontSize: 15, fontWeight: '600', color: Colors.primary }, + actionBtnDestructive: { backgroundColor: Colors.error }, + actionBtnDestructiveText: { fontSize: 15, fontWeight: '600', color: Colors.surface }, +}) diff --git a/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx b/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx new file mode 100644 index 0000000..417b644 --- /dev/null +++ b/apps/mobile/src/screens/announcements/AnnouncementsListScreen.tsx @@ -0,0 +1,315 @@ +import React, { useCallback, useState } from 'react' +import { + View, + Text, + FlatList, + RefreshControl, + Pressable, + ScrollView, + StyleSheet, + TouchableOpacity, + Platform, +} from 'react-native' +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { useFocusEffect } from '@react-navigation/native' +import { ScreenWrapper } from '../../components/ScreenWrapper' +import { LoadingSpinner } from '../../components/LoadingSpinner' +import { Toast } from '../../components/Toast' +import { AppStackParamList } from '../../navigation/AppNavigator' +import { useAuth } from '../../hooks/useAuth' +import { listAnnouncements, Announcement, AnnouncementCategory } from '../../services/announcements' +import { Colors } from '../../constants/colors' +import { Spacing } from '../../constants/spacing' + +type Props = NativeStackScreenProps + +type CategoryFilter = 'ALL' | AnnouncementCategory + +const FILTERS: { label: string; value: CategoryFilter }[] = [ + { label: 'All', value: 'ALL' }, + { label: 'General', value: 'GENERAL' }, + { label: 'Maintenance', value: 'MAINTENANCE' }, + { label: 'Meeting', value: 'MEETING' }, + { label: 'Emergency', value: 'EMERGENCY' }, + { label: 'Celebration', value: 'CELEBRATION' }, +] + +const CATEGORY_ICON: Record = { + GENERAL: '📢', + MAINTENANCE: '🔧', + MEETING: '👥', + EMERGENCY: '🚨', + CELEBRATION: '🎉', +} + +const CATEGORY_COLORS: Record = { + GENERAL: { bg: '#f3f4f6', text: '#6b7280', icon: '#9ca3af' }, + MAINTENANCE: { bg: '#fff7ed', text: '#ea580c', icon: '#f97316' }, + MEETING: { bg: '#eff6ff', text: '#2563eb', icon: '#3b82f6' }, + EMERGENCY: { bg: '#fef2f2', text: '#dc2626', icon: '#ef4444' }, + CELEBRATION: { bg: '#faf5ff', text: '#9333ea', icon: '#a855f7' }, +} + +export function AnnouncementsListScreen({ route, navigation }: Props) { + const { societyId } = route.params + const { permissions } = useAuth() + + const canCreate = permissions.includes('announcement.create') + + const [categoryFilter, setCategoryFilter] = useState('ALL') + const [announcements, setAnnouncements] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' | 'info' } | null>(null) + + const load = useCallback( + async (refreshing = false) => { + try { + if (!refreshing) setIsLoading(true) + const category = categoryFilter === 'ALL' ? undefined : categoryFilter + const data = await listAnnouncements(societyId, category) + setAnnouncements(data) + } catch { + setToast({ message: 'Could not load announcements. Pull to retry.', type: 'error' }) + } finally { + setIsLoading(false) + setIsRefreshing(false) + } + }, + [societyId, categoryFilter], + ) + + useFocusEffect( + useCallback(() => { + load() + }, [load]), + ) + + const onRefresh = useCallback(() => { + setIsRefreshing(true) + load(true) + }, [load]) + + const renderItem = ({ item }: { item: Announcement }) => { + const colors = CATEGORY_COLORS[item.category] + return ( + navigation.navigate('AnnouncementDetail', { societyId, announcementId: item.id })} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {CATEGORY_ICON[item.category]} + + + + + {item.title} + + {item.isPinned ? 📌 : null} + + + {item.body} + + + + + {item.category.charAt(0) + item.category.slice(1).toLowerCase()} + + + · {item.createdBy.name} + · {timeAgo(item.createdAt)} + + + + + ) + } + + if (isLoading) return + + return ( + + {/* Filter chips */} + + {FILTERS.map((f) => ( + setCategoryFilter(f.value)} + style={[styles.chip, categoryFilter === f.value && styles.chipActive]} + > + + {f.label} + + + ))} + + + {/* Announcements list */} + item.id} + renderItem={renderItem} + refreshControl={ + + } + ListEmptyComponent={ + + No announcements yet + + {categoryFilter === 'ALL' + ? 'Nothing posted yet.' + : `No ${categoryFilter.toLowerCase()} announcements.`} + + + } + contentContainerStyle={announcements.length === 0 ? styles.emptyContainer : undefined} + style={styles.list} + /> + + {/* FAB — create announcement */} + {canCreate ? ( + navigation.navigate('CreateAnnouncement', { societyId })} + style={styles.fab} + activeOpacity={0.85} + > + + + + ) : null} + + {toast ? ( + setToast(null)} + /> + ) : null} + + ) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + return new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + wrapper: { backgroundColor: Colors.background }, + + chipBar: { flexGrow: 0, flexShrink: 0 }, + chips: { + paddingHorizontal: Spacing.screenPadding, + paddingVertical: 12, + gap: 8, + alignItems: 'center', + }, + chip: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: Colors.surface, + borderWidth: 1, + borderColor: Colors.border, + }, + chipActive: { + backgroundColor: Colors.primary, + borderColor: Colors.primary, + }, + chipText: { fontSize: 14, fontWeight: '500', color: Colors.subtle }, + chipTextActive: { color: Colors.surface }, + + list: { flex: 1 }, + + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: Spacing.screenPadding, + paddingVertical: 14, + backgroundColor: Colors.surface, + borderBottomWidth: 1, + borderBottomColor: Colors.border, + gap: 12, + minHeight: Spacing.minTapTarget, + }, + rowPressed: { backgroundColor: Colors.background }, + rowIcon: { + width: 40, + height: 40, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + rowIconText: { fontSize: 18 }, + rowContent: { flex: 1, gap: 4 }, + rowTop: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + rowTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: Colors.text }, + pinIcon: { fontSize: 13 }, + rowBody: { fontSize: 13, color: Colors.subtle, lineHeight: 18 }, + rowMeta: { flexDirection: 'row', alignItems: 'center', gap: 4, flexWrap: 'wrap' }, + categoryBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 5, + }, + categoryBadgeText: { fontSize: 11, fontWeight: '600' }, + rowMetaText: { fontSize: 12, color: Colors.subtle }, + chevron: { fontSize: 22, color: Colors.subtle, lineHeight: 26 }, + + emptyContainer: { flexGrow: 1 }, + emptyFull: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 32, + gap: 8, + }, + emptyTitle: { fontSize: 17, fontWeight: '600', color: Colors.text }, + emptySub: { fontSize: 14, color: Colors.subtle, textAlign: 'center' }, + + fab: { + position: 'absolute', + right: 20, + bottom: Platform.OS === 'ios' ? 36 : 24, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: Colors.primary, + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.2, + shadowRadius: 6, + elevation: 6, + }, + fabText: { + fontSize: 28, + color: Colors.surface, + fontWeight: '400', + lineHeight: 34, + marginTop: -2, + }, +}) diff --git a/apps/mobile/src/screens/announcements/CreateAnnouncementScreen.tsx b/apps/mobile/src/screens/announcements/CreateAnnouncementScreen.tsx new file mode 100644 index 0000000..b85f2ab --- /dev/null +++ b/apps/mobile/src/screens/announcements/CreateAnnouncementScreen.tsx @@ -0,0 +1,294 @@ +import React, { useState } from 'react' +import { + View, + Text, + Pressable, + Image, + StyleSheet, + ScrollView, +} from 'react-native' +import * as ImagePicker from 'expo-image-picker' +import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { ScreenWrapper } from '../../components/ScreenWrapper' +import { TextInput } from '../../components/TextInput' +import { Button } from '../../components/Button' +import { Toast } from '../../components/Toast' +import { AppStackParamList } from '../../navigation/AppNavigator' +import { createAnnouncement, AnnouncementCategory } from '../../services/announcements' +import { getApiErrorCode } from '../../services/api' +import { getErrorMessage } from '../../utils/errorMessages' +import { Colors } from '../../constants/colors' +import { Spacing } from '../../constants/spacing' + +type Props = NativeStackScreenProps + +const MAX_IMAGES = 5 + +const CATEGORIES: { label: string; value: AnnouncementCategory }[] = [ + { label: 'General', value: 'GENERAL' }, + { label: 'Maintenance', value: 'MAINTENANCE' }, + { label: 'Meeting', value: 'MEETING' }, + { label: 'Emergency', value: 'EMERGENCY' }, + { label: 'Celebration', value: 'CELEBRATION' }, +] + +interface SelectedImage { + uri: string + base64: string +} + +interface FieldErrors { + title?: string + body?: string +} + +export function CreateAnnouncementScreen({ route, navigation }: Props) { + const { societyId } = route.params + + const [title, setTitle] = useState('') + const [body, setBody] = useState('') + const [category, setCategory] = useState('GENERAL') + const [images, setImages] = useState([]) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [toast, setToast] = useState<{ message: string; type: 'error' | 'success' | 'info' } | null>(null) + + function clearError(field: keyof FieldErrors) { + setErrors((prev) => ({ ...prev, [field]: undefined })) + } + + function validate(): boolean { + const next: FieldErrors = {} + if (!title.trim()) next.title = 'Title is required.' + if (!body.trim()) next.body = 'Body is required.' + setErrors(next) + return Object.keys(next).length === 0 + } + + async function pickImages() { + if (images.length >= MAX_IMAGES) { + setToast({ message: `Maximum ${MAX_IMAGES} images allowed.`, type: 'error' }) + return + } + + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync() + if (status !== 'granted') { + setToast({ message: 'Allow photo access to attach images.', type: 'info' }) + return + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + allowsMultipleSelection: true, + selectionLimit: MAX_IMAGES - images.length, + base64: true, + quality: 0.7, + }) + + if (result.canceled) return + + const picked: SelectedImage[] = (result.assets as Array<{ uri: string; base64?: string | null }>) + .filter((a) => !!a.base64) + .map((a) => ({ uri: a.uri, base64: a.base64! })) + + setImages((prev) => [...prev, ...picked].slice(0, MAX_IMAGES)) + } + + function removeImage(index: number) { + setImages((prev) => prev.filter((_, i) => i !== index)) + } + + async function handleSubmit() { + if (!validate()) return + + setIsSubmitting(true) + try { + await createAnnouncement(societyId, { + title: title.trim(), + body: body.trim(), + category, + images: images.map((img) => img.base64), + }) + + setToast({ message: 'Announcement posted.', type: 'success' }) + setTimeout(() => navigation.goBack(), 1500) + } catch (e) { + const code = getApiErrorCode(e) + if (code === 'missing_field') { + setToast({ message: 'Please fill in all required fields.', type: 'error' }) + } else { + setToast({ message: getErrorMessage(code), type: 'error' }) + } + } finally { + setIsSubmitting(false) + } + } + + return ( + + {/* Title */} + { + setTitle(t) + if (errors.title) clearError('title') + }} + error={errors.title} + maxLength={120} + returnKeyType="next" + /> + + {/* Body */} + { + setBody(t) + if (errors.body) clearError('body') + }} + error={errors.body} + multiline + numberOfLines={4} + style={styles.bodyInput} + textAlignVertical="top" + /> + + {/* Category chips */} + + Category + + {CATEGORIES.map((c) => ( + setCategory(c.value)} + style={[styles.categoryChip, category === c.value && styles.categoryChipActive]} + > + + {c.label} + + + ))} + + + + {/* Image picker */} + + + Photos{' '} + (optional, up to {MAX_IMAGES}) + + + {images.map((img, i) => ( + + + removeImage(i)} hitSlop={8}> + + + + ))} + {images.length < MAX_IMAGES ? ( + + + + Add Photos + + {images.length}/{MAX_IMAGES} + + + ) : null} + + + + {/* Submit */} +