diff --git a/apps/api/src/modules/reports/report-comment.model.ts b/apps/api/src/modules/reports/report-comment.model.ts new file mode 100644 index 0000000..1fac410 --- /dev/null +++ b/apps/api/src/modules/reports/report-comment.model.ts @@ -0,0 +1,52 @@ +import { Schema, model, type HydratedDocument, type Types } from 'mongoose'; + +const COMMENT_VISIBILITIES = ['PUBLIC', 'INTERNAL'] as const; + +export type ReportCommentVisibility = (typeof COMMENT_VISIBILITIES)[number]; + +export interface ReportComment { + reportId: Types.ObjectId; + authorId: string; + authorRole: 'CITIZEN' | 'AGENCY_ADMIN'; + body: string; + visibility: ReportCommentVisibility; +} + +const reportCommentSchema = new Schema( + { + reportId: { + type: Schema.Types.ObjectId, + ref: 'Report', + required: true, + index: true, + }, + authorId: { + type: String, + required: true, + index: true, + }, + authorRole: { + type: String, + enum: ['CITIZEN', 'AGENCY_ADMIN'], + required: true, + }, + body: { + type: String, + required: true, + trim: true, + }, + visibility: { + type: String, + enum: COMMENT_VISIBILITIES, + default: 'PUBLIC', + index: true, + }, + }, + { timestamps: true }, +); + +reportCommentSchema.index({ reportId: 1, createdAt: -1 }); + +export type ReportCommentDocument = HydratedDocument; + +export const ReportCommentModel = model('ReportComment', reportCommentSchema); diff --git a/apps/api/src/modules/reports/reports.controller.ts b/apps/api/src/modules/reports/reports.controller.ts index 8bbefa8..4277e4c 100644 --- a/apps/api/src/modules/reports/reports.controller.ts +++ b/apps/api/src/modules/reports/reports.controller.ts @@ -6,18 +6,25 @@ import { AppError } from '../../core/errors/app-error'; import { logger } from '../../core/logging/logger'; import { MediaDraftModel } from '../media/media-draft.model'; import { MediaUploadModel } from '../media/media-upload.model'; -import { StatusUpdateModel } from './status-update.model'; +import { UserModel } from '../users/user.model'; +import { ReportCommentModel } from './report-comment.model'; import { ReportModel } from './report.model'; import { enqueueStellarAnchor } from './reports.anchor.queue'; +import { buildDeterministicSnapshot, hashSnapshot } from './reports.snapshot'; import { StatusUpdateModel } from './status-update.model'; import { CreateReportDTO, ListReportsQueryDTO, + MyReportsQueryDTO, + PublicReportListQueryDTO, + ReportCommentBodyDTO, + ReportDetailParamsDTO, ReportsMapQueryDTO, UpdateReportStatusDTO, VerifyReportDTO, VerifyStatusDTO, } from './reports.schemas'; +import { type ReportStatus } from './report.model'; const ALLOWED_STATUS_TRANSITIONS: Record = { PENDING: ['ACKNOWLEDGED', 'REJECTED', 'ESCALATED'], @@ -399,6 +406,170 @@ export const getMyReports = async ( } }; +export const getReportById = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { reportId } = req.params as unknown as ReportDetailParamsDTO; + const report = await ReportModel.findById(reportId).lean(); + + if (!report) { + throw new AppError('Report not found', 404, 'REPORT_NOT_FOUND'); + } + + const comments = await ReportCommentModel.find({ + reportId, + ...(req.user?.role === 'AGENCY_ADMIN' ? {} : { visibility: 'PUBLIC' }), + }) + .sort({ createdAt: 1 }) + .lean(); + + return res.status(200).json({ + id: String(report._id), + title: report.title, + description: report.description, + category: report.category, + status: report.status, + anchorStatus: report.anchor_status, + stellarTxHash: report.stellar_tx_hash, + mediaUrls: report.media_urls, + comments, + }); + } catch (error) { + return next(error); + } +}; + +export const addReportComment = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { reportId } = req.params as unknown as ReportDetailParamsDTO; + const { body, visibility } = req.body as ReportCommentBodyDTO; + const report = await ReportModel.findById(reportId).lean(); + + if (!report) { + throw new AppError('Report not found', 404, 'REPORT_NOT_FOUND'); + } + + if (req.user?.role === 'CITIZEN' && report.reporter_user_id !== req.user.id) { + throw new AppError('Forbidden', 403, 'FORBIDDEN'); + } + + const comment = await ReportCommentModel.create({ + reportId: report._id, + authorId: req.user?.id ?? 'unknown', + authorRole: req.user?.role ?? 'CITIZEN', + body, + visibility: req.user?.role === 'AGENCY_ADMIN' ? visibility : 'PUBLIC', + }); + + return res.status(201).json({ + id: String(comment._id), + body: comment.body, + visibility: comment.visibility, + createdAt: (comment as typeof comment & { createdAt?: Date }).createdAt ?? null, + }); + } catch (error) { + return next(error); + } +}; + +export const getReportComments = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { reportId } = req.params as unknown as ReportDetailParamsDTO; + const comments = await ReportCommentModel.find({ + reportId, + ...(req.user?.role === 'AGENCY_ADMIN' ? {} : { visibility: 'PUBLIC' }), + }) + .sort({ createdAt: 1 }) + .lean(); + + return res.status(200).json({ data: comments }); + } catch (error) { + return next(error); + } +}; + +export const listPublicReports = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const query = req.query as unknown as PublicReportListQueryDTO; + const filter: Record = {}; + + if (query.status) { + filter.status = query.status; + } + + if (query.category) { + filter.category = query.category; + } + + const reports = await ReportModel.find(filter) + .sort({ createdAt: -1 }) + .skip((query.page - 1) * query.pageSize) + .limit(query.pageSize) + .lean(); + + return res.status(200).json({ + data: reports.map((report) => ({ + id: String(report._id), + title: report.title, + description: report.description, + category: report.category, + status: report.status, + location: report.location, + anchorStatus: report.anchor_status, + })), + }); + } catch (error) { + return next(error); + } +}; + +export const getPublicReportById = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { reportId } = req.params as unknown as ReportDetailParamsDTO; + const report = await ReportModel.findById(reportId).lean(); + + if (!report) { + throw new AppError('Report not found', 404, 'REPORT_NOT_FOUND'); + } + + const comments = await ReportCommentModel.find({ reportId, visibility: 'PUBLIC' }) + .sort({ createdAt: 1 }) + .lean(); + + return res.status(200).json({ + id: String(report._id), + title: report.title, + description: report.description, + category: report.category, + status: report.status, + location: report.location, + anchorStatus: report.anchor_status, + comments, + }); + } catch (error) { + return next(error); + } +}; + export const verifyStatus = async ( req: Request, res: Response, @@ -518,70 +689,6 @@ export const getMapReports = async ( } }; -export const getReportById = async ( - req: Request, - res: Response, - next: NextFunction, -) => { - try { - const query = req.query as unknown as ReportListQueryDTO; - const filter: Record = {}; - - if (query.status) { - filter.status = query.status; - } - - if (query.category) { - filter.category = query.category; - } - - if (query.mine && req.user?.id) { - filter.reporter_user_id = req.user.id; - } - - const page = query.page; - const pageSize = query.pageSize; - - const [reports, total] = await Promise.all([ - ReportModel.find(filter) - .sort({ createdAt: -1 }) - .skip((page - 1) * pageSize) - .limit(pageSize) - .lean() - .exec(), - ReportModel.countDocuments(filter), - ]); - const typedReports = reports as Array<{ - _id: unknown; - title: string; - category: string; - status: string; - anchor_status: string; - integrity_flag: string; - location: { type: 'Point'; coordinates: [number, number] }; - createdAt?: Date; - }>; - - return res.status(200).json({ - page, - pageSize, - total, - data: typedReports.map((report) => ({ - id: String(report._id), - title: report.title, - category: report.category, - status: report.status, - anchor_status: report.anchor_status, - integrity_flag: report.integrity_flag, - created_at: report.createdAt ?? null, - location: report.location, - })), - }); - } catch (error) { - return next(error); - } -}; - export const getReportDetail = async ( req: Request, res: Response, diff --git a/apps/api/src/modules/reports/reports.routes.ts b/apps/api/src/modules/reports/reports.routes.ts index d4e49c2..329698b 100644 --- a/apps/api/src/modules/reports/reports.routes.ts +++ b/apps/api/src/modules/reports/reports.routes.ts @@ -1,7 +1,12 @@ import { Router } from 'express'; import { + addReportComment, createReport, + getPublicReportById, + getReportDetail, + getReportComments, listReports, + listPublicReports, getMapReports, getMyReports, verifyReport, @@ -12,9 +17,12 @@ import { authenticateToken, requireRole } from '../auth/auth.middleware'; import { validateRequest } from '../../core/validation/validate-request'; import { stellarAnchoringRateLimiter } from '../../core/rate-limit/rate-limit.middleware'; import { - anchorIssuesQuerySchema, createReportBodySchema, + myReportsQuerySchema, listReportsQuerySchema, + publicReportListQuerySchema, + reportCommentBodySchema, + reportDetailParamsSchema, reportsMapQuerySchema, updateReportStatusBodySchema, verifyReportBodySchema, @@ -23,6 +31,18 @@ import { const router: Router = Router(); +router.get( + '/public', + validateRequest({ query: publicReportListQuerySchema }), + listPublicReports, +); + +router.get( + '/public/:reportId', + validateRequest({ params: reportDetailParamsSchema }), + getPublicReportById, +); + router.get( '/', authenticateToken, @@ -60,8 +80,24 @@ router.get( '/:reportId', authenticateToken, requireRole(['CITIZEN', 'AGENCY_ADMIN']), - validateRequest({ params: reportParamsSchema }), - getReportById, + validateRequest({ params: reportDetailParamsSchema }), + getReportDetail, +); + +router.get( + '/:reportId/comments', + authenticateToken, + requireRole(['CITIZEN', 'AGENCY_ADMIN']), + validateRequest({ params: reportDetailParamsSchema }), + getReportComments, +); + +router.post( + '/:reportId/comments', + authenticateToken, + requireRole(['CITIZEN', 'AGENCY_ADMIN']), + validateRequest({ params: reportDetailParamsSchema, body: reportCommentBodySchema }), + addReportComment, ); router.post( diff --git a/apps/api/src/modules/reports/reports.schemas.ts b/apps/api/src/modules/reports/reports.schemas.ts index c25e997..8b8e3fa 100644 --- a/apps/api/src/modules/reports/reports.schemas.ts +++ b/apps/api/src/modules/reports/reports.schemas.ts @@ -169,6 +169,10 @@ export const reportListQuerySchema = z.object({ pageSize: positiveInt('pageSize', 20).refine((value) => value <= 100, 'pageSize must be <= 100'), status: optionalTrimmed(), category: optionalTrimmed(), + sort: z.enum(['createdAt', 'updatedAt']).optional(), + order: z.enum(['asc', 'desc']).optional(), + reporterId: optionalTrimmed(), + district: optionalTrimmed(), mine: z .enum(['true', 'false']) .optional() @@ -179,9 +183,27 @@ export const reportDetailParamsSchema = z.object({ reportId: trimmed('reportId'), }); +export const reportCommentBodySchema = z.object({ + body: trimmed('body'), + visibility: z.enum(['PUBLIC', 'INTERNAL']).default('PUBLIC'), +}); + +export const publicReportListQuerySchema = z.object({ + page: positiveInt('page', 1), + pageSize: positiveInt('pageSize', 12).refine((value) => value <= 100, 'pageSize must be <= 100'), + status: optionalTrimmed(), + category: optionalTrimmed(), +}); + +export const listReportsQuerySchema = reportListQuerySchema; + export type CreateReportDTO = z.infer; export type VerifyReportDTO = z.infer; export type UpdateReportStatusDTO = z.infer; export type VerifyStatusDTO = z.infer; export type ReportsMapQueryDTO = z.infer; -export type ListReportsQueryDTO = z.infer; +export type ListReportsQueryDTO = z.infer; +export type MyReportsQueryDTO = z.infer; +export type ReportDetailParamsDTO = z.infer; +export type ReportCommentBodyDTO = z.infer; +export type PublicReportListQueryDTO = z.infer;