Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/api/src/modules/reports/report-comment.model.ts
Original file line number Diff line number Diff line change
@@ -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<ReportComment>(
{
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<ReportComment>;

export const ReportCommentModel = model<ReportComment>('ReportComment', reportCommentSchema);
237 changes: 172 additions & 65 deletions apps/api/src/modules/reports/reports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReportStatus, ReportStatus[]> = {
PENDING: ['ACKNOWLEDGED', 'REJECTED', 'ESCALATED'],
Expand Down Expand Up @@ -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<string, unknown> = {};

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,
Expand Down Expand Up @@ -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<string, unknown> = {};

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,
Expand Down
42 changes: 39 additions & 3 deletions apps/api/src/modules/reports/reports.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Router } from 'express';
import {
addReportComment,
createReport,
getPublicReportById,
getReportDetail,
getReportComments,
listReports,
listPublicReports,
getMapReports,
getMyReports,
verifyReport,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading