diff --git a/app/migrations/20240308152708-add-cityId-to-userFile.cjs b/app/migrations/20240308152708-add-cityId-to-userFile.cjs new file mode 100644 index 000000000..fc97f1739 --- /dev/null +++ b/app/migrations/20240308152708-add-cityId-to-userFile.cjs @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('UserFile', 'city_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'City', + key: 'city_id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn('UserFile', 'city_id'); + } +}; \ No newline at end of file diff --git a/app/src/app/[lng]/data/review/page.tsx b/app/src/app/[lng]/data/review/page.tsx index fb1ffde72..1af792c1d 100644 --- a/app/src/app/[lng]/data/review/page.tsx +++ b/app/src/app/[lng]/data/review/page.tsx @@ -59,6 +59,15 @@ export default function ReviewPage({ const [isConfirming, setIsConfirming] = useState(false); + const { data: userInfo, isLoading: isUserInfoLoading } = + api.useGetUserInfoQuery(); + + const { data: inventory } = api.useGetInventoryQuery( + userInfo?.defaultInventoryId!, + { skip: !userInfo }, + ); + const cityId = inventory?.city.cityId; + const onConfirm = async () => { setIsConfirming(true); try { @@ -79,7 +88,7 @@ export default function ReviewPage({ formData.append("data", file, file.name); } - await addUserFile(formData).then(() => { + await addUserFile({ formData, cityId }).then(() => { // TODO // Trigger notification to user }); diff --git a/app/src/app/[lng]/settings/page.tsx b/app/src/app/[lng]/settings/page.tsx index a70d0b402..e9434cae3 100644 --- a/app/src/app/[lng]/settings/page.tsx +++ b/app/src/app/[lng]/settings/page.tsx @@ -63,7 +63,7 @@ export default function Settings({ { skip: !cityId }, ); - const { data: userFiles } = api.useGetUserFilesQuery({ + const { data: userFiles } = api.useGetUserFilesQuery(cityId!, { skip: !userInfo, }); diff --git a/app/src/app/api/v0/user/file/[file]/download-file/route.ts b/app/src/app/api/v0/city/[city]/file/[file]/download-file/route.ts similarity index 94% rename from app/src/app/api/v0/user/file/[file]/download-file/route.ts rename to app/src/app/api/v0/city/[city]/file/[file]/download-file/route.ts index 62da7fd98..5187cfc50 100644 --- a/app/src/app/api/v0/user/file/[file]/download-file/route.ts +++ b/app/src/app/api/v0/city/[city]/file/[file]/download-file/route.ts @@ -5,7 +5,6 @@ import createHttpError from "http-errors"; import { NextResponse } from "next/server"; export const GET = apiHandler(async (_req, context) => { - const userId = context.session?.user.id; if (!context.session) { throw new createHttpError.Unauthorized("Unauthorized"); } @@ -13,7 +12,7 @@ export const GET = apiHandler(async (_req, context) => { const userFile = await db.models.UserFile.findOne({ where: { id: context.params.file, - userId, + cityId: context.params.city, }, }); diff --git a/app/src/app/api/v0/city/[city]/file/[file]/route.ts b/app/src/app/api/v0/city/[city]/file/[file]/route.ts new file mode 100644 index 000000000..6b7a2341a --- /dev/null +++ b/app/src/app/api/v0/city/[city]/file/[file]/route.ts @@ -0,0 +1,46 @@ +import { db } from "@/models"; +import { apiHandler } from "@/util/api"; +import createHttpError from "http-errors"; +import { NextResponse } from "next/server"; + +export const GET = apiHandler(async (_req: Request, context) => { + const userId = context.session?.user.id; + if (!context.session) { + throw new createHttpError.Unauthorized("Unauthorized"); + } + + const userFile = await db.models.UserFile.findOne({ + where: { + id: context.params.file, + cityId: context.params.city, + }, + }); + + if (!userFile) { + throw new createHttpError.NotFound("User file not found"); + } + + return NextResponse.json({ data: userFile }); +}); + +export const DELETE = apiHandler(async (_req: Request, context) => { + const userId = context.session?.user.id; + if (!context.session) { + throw new createHttpError.Unauthorized("Unauthorized"); + } + + const userFile = await db.models.UserFile.findOne({ + where: { + id: context.params.file, + cityId: context.params.city, + }, + }); + + if (!userFile) { + throw new createHttpError.NotFound("User file not found"); + } + + await userFile.destroy(); + + return NextResponse.json({ data: userFile, deleted: true }); +}); diff --git a/app/src/app/api/v0/user/file/route.ts b/app/src/app/api/v0/city/[city]/file/route.ts similarity index 95% rename from app/src/app/api/v0/user/file/route.ts rename to app/src/app/api/v0/city/[city]/file/route.ts index dde8fe7d1..89fde54d5 100644 --- a/app/src/app/api/v0/user/file/route.ts +++ b/app/src/app/api/v0/city/[city]/file/route.ts @@ -12,14 +12,13 @@ const MAX_FILE_SIZE = 5000000; const ACCEPTED_FILE_FORMATS = []; // file formats types to be parsed and refined later export const GET = apiHandler(async (_req: Request, context) => { - const userId = context.session?.user.id; if (!context.session) { throw new createHttpError.Unauthorized("Unauthorized"); } const userFiles = await db.models.UserFile.findAll({ where: { - userId: userId, + cityId: context.params.city, }, }); @@ -38,7 +37,8 @@ export const GET = apiHandler(async (_req: Request, context) => { }); return { id: userFile.id, - userId: userFile.id, + userId: userFile.userId, + cityId: userFile.cityId, fileReference: userFile.fileReference, url: userFile.url, sector: userFile.sector, @@ -78,6 +78,7 @@ export const POST = apiHandler(async (req: NextRequest, context) => { const fileData = { userId: userId, + cityId: context.params.city, fileReference: formData.get("fileReference"), url: formData.get("url"), data: buffer, @@ -103,6 +104,7 @@ export const POST = apiHandler(async (req: NextRequest, context) => { data: { id: userFile.id, userId: userFile.userId, + cityId: userFile.cityId, fileReference: userFile.fileReference, url: userFile.url, sector: userFile.sector, diff --git a/app/src/app/api/v0/user/file/[file]/route.ts b/app/src/app/api/v0/user/file/[file]/route.ts deleted file mode 100644 index c6d55c154..000000000 --- a/app/src/app/api/v0/user/file/[file]/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { db } from "@/models"; -import { apiHandler } from "@/util/api"; -import createHttpError from "http-errors"; -import { NextResponse } from "next/server"; - -export const GET = apiHandler( - async ( - _req: Request, - context, - ) => { - const userId = context.session?.user.id; - if (!context.session) { - throw new createHttpError.Unauthorized("Unauthorized"); - } - - const userFile = await db.models.UserFile.findOne({ - where: { - id: context.params.file, - userId, - }, - }); - - if (!userFile) { - throw new createHttpError.NotFound("User file not found"); - } - - return NextResponse.json({ data: userFile }); - }, -); - -export const DELETE = apiHandler( - async ( - _req: Request, - context, - ) => { - const userId = context.session?.user.id; - if (!context.session) { - throw new createHttpError.Unauthorized("Unauthorized"); - } - - const userFile = await db.models.UserFile.findOne({ - where: { - id: context.params.file, - userId, - }, - }); - - if (!userFile) { - throw new createHttpError.NotFound("User file not found"); - } - - await userFile.destroy(); - - return NextResponse.json({ data: userFile, deleted: true }); - }, -); diff --git a/app/src/components/Modals/delete-file-modal.tsx b/app/src/components/Modals/delete-file-modal.tsx index 25e9ecaf2..a5a9e6f61 100644 --- a/app/src/components/Modals/delete-file-modal.tsx +++ b/app/src/components/Modals/delete-file-modal.tsx @@ -37,7 +37,7 @@ const DeleteFileModal: FC = ({ const [deleteUserFile] = api.useDeleteUserFileMutation(); const onDeleteFile = async () => { try { - await deleteUserFile({ fileId: fileData?.id }); + await deleteUserFile({ fileId: fileData?.id, cityId: fileData?.cityId }); } catch (error) { console.error(error); } finally { diff --git a/app/src/components/Tabs/my-files-tab.tsx b/app/src/components/Tabs/my-files-tab.tsx index ce594bb04..d5922d63e 100644 --- a/app/src/components/Tabs/my-files-tab.tsx +++ b/app/src/components/Tabs/my-files-tab.tsx @@ -444,7 +444,7 @@ const MyFilesTab: FC = ({ }} > diff --git a/app/src/models/UserFile.ts b/app/src/models/UserFile.ts index 3f4c6bcad..25d51573e 100644 --- a/app/src/models/UserFile.ts +++ b/app/src/models/UserFile.ts @@ -5,6 +5,7 @@ import { User, UserId } from "./User"; export interface UserFileAttributes { id: string; userId?: string; + cityId?: string; fileReference?: string; data?: Buffer | any; fileType?: string; @@ -21,6 +22,7 @@ export type UserFilePk = "id"; export type UserFileId = UserFile[UserFilePk]; export type UserFileOptionalAttributes = | "userId" + | "cityId" | "fileReference" | "data" | "fileType" @@ -42,6 +44,7 @@ export class UserFile { id!: string; userId?: string; + cityId?: string; fileReference?: string; data?: Buffer; fileType?: string; @@ -77,6 +80,15 @@ export class UserFile }, field: "user_id", }, + cityId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "City", + key: "city_id", + }, + field: "city_id", + }, fileReference: { type: DataTypes.STRING(255), allowNull: true, diff --git a/app/src/models/init-models.ts b/app/src/models/init-models.ts index 6ff4f8f05..bbf517e49 100644 --- a/app/src/models/init-models.ts +++ b/app/src/models/init-models.ts @@ -601,6 +601,8 @@ export function initModels(sequelize: Sequelize) { }); User.hasMany(UserFile, { foreignKey: "userId", as: "user" }); UserFile.belongsTo(User, { as: "userFiles", foreignKey: "userId" }); + UserFile.belongsTo(City, { foreignKey: "cityId", as: "city" }); + City.hasMany(UserFile, { foreignKey: "cityId", as: "userFiles" }); City.hasMany(CityInvite, { as: "cityInvite", foreignKey: "cityId" }); CityInvite.belongsTo(City, { as: "cityInvites", foreignKey: "cityId" }); GasValue.belongsTo(InventoryValue, { diff --git a/app/src/services/api.ts b/app/src/services/api.ts index 31cd21a25..29f3fae14 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -319,10 +319,10 @@ export const api = createApi({ transformResponse: (response: { data: any }) => response.data, }), addUserFile: builder.mutation({ - query: (formData) => { + query: ({ formData, cityId }) => { return { method: "POST", - url: `/user/file`, + url: `city/${cityId}/file`, body: formData, }; }, @@ -331,9 +331,9 @@ export const api = createApi({ invalidatesTags: ["FileData"], }), getUserFiles: builder.query({ - query: () => ({ + query: (cityId: string) => ({ method: "GET", - url: `/user/file`, + url: `/city/${cityId}/file`, }), transformResponse: (response: { data: UserFileResponse }) => { return response.data; @@ -344,7 +344,7 @@ export const api = createApi({ deleteUserFile: builder.mutation({ query: (params) => ({ method: "DELETE", - url: `/user/file/${params.fileId}`, + url: `/city/${params.cityId}/file/${params.fileId}`, }), transformResponse: (response: { data: UserFileResponse }) => response.data, diff --git a/app/src/util/validation.ts b/app/src/util/validation.ts index f8f63785d..a66e13713 100644 --- a/app/src/util/validation.ts +++ b/app/src/util/validation.ts @@ -115,7 +115,8 @@ export type CreatePopulationRequest = z.infer; // user file schema validation export const createUserFileRequset = z.object({ - userId: z.string().uuid().optional(), + userId: z.string().uuid(), + cityId: z.string().uuid(), fileReference: z.string().optional(), data: z.any(), fileType: z.string().optional(), diff --git a/app/tests/api/userfile.test.ts b/app/tests/api/userfile.test.ts index 731aebfd6..f9736a899 100644 --- a/app/tests/api/userfile.test.ts +++ b/app/tests/api/userfile.test.ts @@ -1,12 +1,12 @@ import { POST as createUserFile, GET as findUserFiles, -} from "@/app/api/v0/user/file/route"; +} from "@/app/api/v0/city/[city]/file/route"; import { DELETE as deleteUserfile, GET as findUserFile, -} from "@/app/api/v0/user/file/[file]/route"; +} from "@/app/api/v0/city/[city]/file/[file]/route"; import { db } from "@/models"; import assert from "node:assert"; @@ -19,6 +19,7 @@ import { mockRequestFormData, setupTests, testUserID, + testCityID, } from "../helpers"; import { randomUUID } from "node:crypto"; import fs from "fs"; @@ -31,6 +32,7 @@ enum STATUS { const fileData = { id: randomUUID(), userId: testUserID, + cityId: testCityID, sector: "Energy Sector", url: "http://www.acme.com", status: STATUS.INPROGRESS, @@ -44,6 +46,7 @@ const invalidFileData = { userId: "2", sector: "333", url: "invalid.com", + cityId: "XXINVALID_ID", status: "7", data: "", gpc_ref_no: "43", @@ -56,6 +59,7 @@ describe("UserFile API", () => { await db.initialize(); await db.models.UserFile.destroy({ where: { userId: testUserID } }); await db.models.User.upsert({ userId: testUserID, name: "TEST_USER" }); + await db.models.City.upsert({ cityId: testCityID, name: "TEST_CITY" }); }); after(async () => { if (db.sequelize) await db.sequelize.close(); @@ -72,8 +76,9 @@ describe("UserFile API", () => { const fileStream = await getFileDataFromStream(path); const formData = new FormData(); - formData.append("id", randomUUID()); + formData.append("id", "9218f7d2-383c-43ab-8c05-867bc783e672"); formData.append("userId", fileData.userId); + formData.append("cityId", fileData.cityId); formData.append("sector", fileData.sector); formData.append("url", fileData.url); formData.append("data", fileStream); @@ -81,10 +86,12 @@ describe("UserFile API", () => { formData.append("fileReference", fileData.file_reference); formData.append("gpcRefNo", fileData.gpc_ref_no); const req = mockRequestFormData(formData); - const res = await createUserFile(req, { params: { user: testUserID } }); + const res = await createUserFile(req, { + params: { user: testUserID, city: testCityID }, + }); assert.equal(res.status, 200); const { data } = await res.json(); - assert.equal(data?.sector, fileData.sector); + assert.equal(data?.sector, fileData?.sector); assert.equal(data?.url, fileData.url); assert.equal(data?.status, fileData.status); assert.equal(data?.gpcRefNo, fileData.gpc_ref_no); @@ -98,6 +105,7 @@ describe("UserFile API", () => { const formData = new FormData(); formData.append("id", invalidFileData.id); formData.append("userId", invalidFileData.userId); + formData.append("cityId", invalidFileData.cityId); formData.append("sector", invalidFileData.sector); formData.append("url", invalidFileData.url); formData.append("data", fileStream); @@ -112,7 +120,7 @@ describe("UserFile API", () => { it("should find all user files", async () => { const req = mockRequest(); const res = await findUserFiles(req, { - params: { user: testUserID }, + params: { user: testUserID, city: testCityID }, }); const { data } = await res.json(); @@ -126,17 +134,16 @@ describe("UserFile API", () => { it("should find a user file", async () => { const getFilesReq = mockRequest(); const getFilesRes = await findUserFiles(getFilesReq, { - params: { user: testUserID }, + params: { user: testUserID, file: randomUUID(), city: testCityID }, }); const { data: userFilesData } = await getFilesRes.json(); const userFiles = userFilesData[0]; const req = mockRequest(); const res = await findUserFile(req, { - params: { user: testUserID, file: userFiles.id }, + params: { user: testUserID, file: userFiles.id, city: testCityID }, }); const { data: userFile } = await res.json(); - assert.equal(userFile?.sector, fileData.sector); assert.equal(userFile?.url, fileData.url); assert.equal(userFile?.status, fileData.status); @@ -146,7 +153,7 @@ describe("UserFile API", () => { it("should not find a user file", async () => { const req = mockRequest(); const res = await findUserFile(req, { - params: { user: testUserID, file: randomUUID() }, + params: { user: testUserID, file: randomUUID(), city: testCityID }, }); assert.equal(res.status, 404); @@ -159,17 +166,20 @@ describe("UserFile API", () => { formData.append("userId", fileData.userId); formData.append("sector", fileData.sector); formData.append("url", fileData.url); + formData.append("cityId", fileData.cityId); formData.append("data", fileStream); formData.append("status", fileData.status); formData.append("fileReference", fileData.file_reference); formData.append("gpcRefNo", fileData.gpc_ref_no); const req = mockRequestFormData(formData); - const res = await createUserFile(req, { params: { user: testUserID } }); + const res = await createUserFile(req, { + params: { user: testUserID, city: testCityID }, + }); assert.equal(res.status, 200); const { data } = await res.json(); const deletRequest = mockRequest(); const deleteResponse = await deleteUserfile(deletRequest, { - params: { user: testUserID, file: data.id }, + params: { user: testUserID, file: data.id, city: testCityID }, }); const { deleted } = await deleteResponse.json(); @@ -180,7 +190,7 @@ describe("UserFile API", () => { it("should not delete a non-existent user file", async () => { const deletRequest = mockRequest(); const deleteResponse = await deleteUserfile(deletRequest, { - params: { user: testUserID, file: randomUUID() }, + params: { user: testUserID, file: randomUUID(), city: testCityID }, }); assert.equal(deleteResponse.status, 404); diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts index 5bc1c0d54..26fe93901 100644 --- a/app/tests/helpers.ts +++ b/app/tests/helpers.ts @@ -75,6 +75,8 @@ export const testFileFormat = { }; export const testUserID = "beb9634a-b68c-4c1b-a20b-2ab0ced5e3c2"; +export const testCityID = "ceb9634a-b68c-4c1b-a20b-2ab0ced5e3cc"; + export const testUserData = { id: testUserID, name: "Test User",