diff --git a/.github/environments/values.dev.yaml b/.github/environments/values.dev.yaml index 7b6bb0c8..9660872a 100644 --- a/.github/environments/values.dev.yaml +++ b/.github/environments/values.dev.yaml @@ -4,6 +4,7 @@ config: configMap: FRONTEND_APIPATH: api/v1 FRONTEND_COMS_APIPATH: https://coms-dev.api.gov.bc.ca/api/v1 + FRONTEND_COMS_BUCKETID: 1f9e1451-c130-4804-aeb0-b78b5b109c47 FRONTEND_OIDC_AUTHORITY: https://dev.loginproxy.gov.bc.ca/auth/realms/standard FRONTEND_OIDC_CLIENTID: nr-permit-connect-navigator-service-5188 SERVER_APIPATH: /api/v1 diff --git a/app/config/custom-environment-variables.json b/app/config/custom-environment-variables.json index c0a8a472..278a9191 100644 --- a/app/config/custom-environment-variables.json +++ b/app/config/custom-environment-variables.json @@ -1,6 +1,10 @@ { "frontend": { "apiPath": "FRONTEND_APIPATH", + "coms": { + "apiPath": "FRONTEND_COMS_APIPATH", + "bucketId": "FRONTEND_COMS_BUCKETID" + }, "notificationBanner": "FRONTEND_NOTIFICATION_BANNER", "oidc": { "authority": "FRONTEND_OIDC_AUTHORITY", diff --git a/app/src/controllers/document.ts b/app/src/controllers/document.ts new file mode 100644 index 00000000..cc769689 --- /dev/null +++ b/app/src/controllers/document.ts @@ -0,0 +1,39 @@ +import { documentService } from '../services'; + +import type { NextFunction, Request, Response } from '../interfaces/IExpress'; + +const controller = { + async createDocument( + req: Request< + never, + never, + { documentId: string; submissionId: string; filename: string; mimeType: string; length: number } + >, + res: Response, + next: NextFunction + ) { + try { + const response = await documentService.createDocument( + req.body.documentId, + req.body.submissionId, + req.body.filename, + req.body.mimeType, + req.body.length + ); + res.status(200).send(response); + } catch (e: unknown) { + next(e); + } + }, + + async listDocuments(req: Request<{ submissionId: string }>, res: Response, next: NextFunction) { + try { + const response = await documentService.listDocuments(req.params.submissionId); + res.status(200).send(response); + } catch (e: unknown) { + next(e); + } + } +}; + +export default controller; diff --git a/app/src/controllers/index.ts b/app/src/controllers/index.ts index 306703e8..d1619371 100644 --- a/app/src/controllers/index.ts +++ b/app/src/controllers/index.ts @@ -1,2 +1,3 @@ export { default as chefsController } from './chefs'; +export { default as documentController } from './document'; export { default as userController } from './user'; diff --git a/app/src/db/README.md b/app/src/db/README.md new file mode 100644 index 00000000..eb03272c --- /dev/null +++ b/app/src/db/README.md @@ -0,0 +1,33 @@ +# NR PermitConnect Navigator Service Database + +## Directory Structure + +```txt +db/ - Database Root +├── migrations/ - Knex database migrations files +├── models/ - Database/Application conversion layer +├── prisma/ - Location of the Prisma schema +└── utils/ - Utility functions +dataConnection.ts - Defines the Prisma database connection +stamps.ts - Defines default timestamp columns +``` + +## Models + +The files in `models/` contain two key sections: type definitions, and `toPrismaModel`/`fromPrismaModel` conversion functions. + +The type definitions are necessary to generate the appropriate hard typings for the conversions. They do not need to be exported as they should never need to be referenced outsite their respective files. + +Due to the way Prisma handles foreign keys multiple types may need to be created. + +Types beginning with `PrismaRelation` are type definitions for an object going to the database. This type may or may not include relational information, but for consistency are named with the same prefix. + +Types beginning with `PrismaGraph` are type definitions for an object coming from the database. The incoming type may also begin with `PrismaRelation` - it depends if there is any relational information required or not. + +See `user.ts` and `document.ts` for examples of the differences. + +The `toPrismaModel` and `fromPrismaModel` functions are used to convert Prisma database models to application `src/types/` and vice versa. These functions should only ever be used in the application service layer. + +## Future Considerations + +Consider the use of namespaces/modules to wrap particular sections of the application. As more initiatives are added to the system there will be naming conflicts. diff --git a/app/src/db/migrations/20231212000000_init.ts b/app/src/db/migrations/20231212000000_init.ts index 9a885893..cae7137d 100644 --- a/app/src/db/migrations/20231212000000_init.ts +++ b/app/src/db/migrations/20231212000000_init.ts @@ -70,6 +70,24 @@ export async function up(knex: Knex): Promise { }) ) + .then(() => + knex.schema.createTable('document', (table) => { + table.uuid('documentId').primary(); + table + .uuid('submissionId') + .notNullable() + .references('submissionId') + .inTable('submission') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + table.text('filename').notNullable(); + table.text('mimeType').defaultTo('application/octet-stream').notNullable(); + table.bigInteger('filesize').notNullable(); + stamps(knex, table); + table.unique(['documentId', 'submissionId']); + }) + ) + // Create audit schema and logged_actions table .then(() => knex.schema.raw('CREATE SCHEMA IF NOT EXISTS audit')) @@ -147,6 +165,12 @@ export async function up(knex: Knex): Promise { FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) ) + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_document_trigger + AFTER UPDATE OR DELETE ON document + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + // Populate Baseline Data .then(() => { const users = ['system']; @@ -165,6 +189,7 @@ export async function down(knex: Knex): Promise { return ( Promise.resolve() // Drop audit triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_document_trigger ON document')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_submission_trigger ON submission')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_user_trigger ON "user"')) .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_identity_provider_trigger ON identity_provider')) @@ -173,6 +198,7 @@ export async function down(knex: Knex): Promise { .then(() => knex.schema.withSchema('audit').dropTableIfExists('logged_actions')) .then(() => knex.schema.dropSchemaIfExists('audit')) // Drop public schema COMS tables + .then(() => knex.schema.dropTableIfExists('document')) .then(() => knex.schema.dropTableIfExists('submission')) .then(() => knex.schema.dropTableIfExists('user')) .then(() => knex.schema.dropTableIfExists('identity_provider')) diff --git a/app/src/db/models/document.ts b/app/src/db/models/document.ts new file mode 100644 index 00000000..b41ca805 --- /dev/null +++ b/app/src/db/models/document.ts @@ -0,0 +1,50 @@ +import { Prisma } from '@prisma/client'; +import disconnectRelation from '../utils/disconnectRelation'; + +import type { IStamps } from '../../interfaces/IStamps'; +import type { Document } from '../../types'; + +// Define types +const _document = Prisma.validator()({}); +const _documentWithGraph = Prisma.validator()({}); + +type SubmissionRelation = { + submission: + | { + connect: { + submissionId: string; + }; + } + | { + disconnect: boolean; + }; +}; + +type PrismaRelationDocument = Omit, 'submissionId' | keyof IStamps> & + SubmissionRelation; + +type PrismaGraphDocument = Prisma.documentGetPayload; + +export default { + toPrismaModel(input: Document): PrismaRelationDocument { + return { + documentId: input.documentId as string, + filename: input.filename, + mimeType: input.mimeType, + filesize: BigInt(input.filesize), + submission: input.submissionId ? { connect: { submissionId: input.submissionId } } : disconnectRelation + }; + }, + + fromPrismaModel(input: PrismaGraphDocument | null): Document | null { + if (!input) return null; + + return { + documentId: input.documentId, + filename: input.filename, + mimeType: input.mimeType, + filesize: Number(input.filesize), + submissionId: input.submissionId as string + }; + } +}; diff --git a/app/src/db/models/identity_provider.ts b/app/src/db/models/identity_provider.ts index dfe11ec0..6b74d781 100644 --- a/app/src/db/models/identity_provider.ts +++ b/app/src/db/models/identity_provider.ts @@ -5,17 +5,17 @@ import type { IdentityProvider } from '../../types'; // Define a type const _identityProvider = Prisma.validator()({}); -type DBIdentityProvider = Omit, keyof IStamps>; +type PrismaRelationIdentityProvider = Omit, keyof IStamps>; export default { - toDBModel(input: IdentityProvider): DBIdentityProvider { + toPrismaModel(input: IdentityProvider): PrismaRelationIdentityProvider { return { idp: input.idp, active: input.active }; }, - fromDBModel(input: DBIdentityProvider | null): IdentityProvider | null { + fromPrismaModel(input: PrismaRelationIdentityProvider | null): IdentityProvider | null { if (!input) return null; return { diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index b97eaeea..b81dde00 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,3 +1,4 @@ +export { default as document } from './document'; export { default as identity_provider } from './identity_provider'; export { default as submission } from './submission'; export { default as user } from './user'; diff --git a/app/src/db/models/submission.ts b/app/src/db/models/submission.ts index d9a8453b..21519099 100644 --- a/app/src/db/models/submission.ts +++ b/app/src/db/models/submission.ts @@ -22,13 +22,16 @@ type UserRelation = { disconnect: boolean; }; }; -type DBSubmission = Omit, 'assignedToUserId' | keyof IStamps> & +type PrismaRelationSubmission = Omit< + Prisma.submissionGetPayload, + 'assignedToUserId' | keyof IStamps +> & UserRelation; -type Submission = Prisma.submissionGetPayload; +type PrismaGraphSubmission = Prisma.submissionGetPayload; export default { - toDBModel(input: ChefsSubmissionForm): DBSubmission { + toPrismaModel(input: ChefsSubmissionForm): PrismaRelationSubmission { return { submissionId: input.submissionId, confirmationId: input.confirmationId, @@ -65,7 +68,7 @@ export default { }; }, - fromDBModel(input: Submission | null): ChefsSubmissionForm | null { + fromPrismaModel(input: PrismaGraphSubmission | null): ChefsSubmissionForm | null { if (!input) return null; return { @@ -98,7 +101,7 @@ export default { waitingOn: input.waitingOn, bringForwardDate: input.bringForwardDate?.toISOString() ?? null, notes: input.notes, - user: user.fromDBModel(input.user), + user: user.fromPrismaModel(input.user), intakeStatus: input.intakeStatus, applicationStatus: input.applicationStatus }; diff --git a/app/src/db/models/user.ts b/app/src/db/models/user.ts index 84af776c..603cb880 100644 --- a/app/src/db/models/user.ts +++ b/app/src/db/models/user.ts @@ -1,14 +1,15 @@ import { Prisma } from '@prisma/client'; import type { IStamps } from '../../interfaces/IStamps'; -import type { User } from '../../types'; +import type { User } from '../../types/User'; // Define types const _user = Prisma.validator()({}); -type DBUser = Omit, keyof IStamps>; + +type PrismaRelationUser = Omit, keyof IStamps>; export default { - toDBModel(input: User): DBUser { + toPrismaModel(input: User): PrismaRelationUser { return { userId: input.userId as string, identityId: input.identityId, @@ -22,7 +23,7 @@ export default { }; }, - fromDBModel(input: DBUser | null): User | null { + fromPrismaModel(input: PrismaRelationUser | null): User | null { if (!input) return null; return { diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index f2100484..85077eb9 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -30,10 +30,10 @@ model knex_migrations_lock { } model submission { - submissionId String @id @db.Uuid - assignedToUserId String? @db.Uuid + submissionId String @id @db.Uuid + assignedToUserId String? @db.Uuid confirmationId String - submittedAt DateTime @db.Timestamptz(6) + submittedAt DateTime @db.Timestamptz(6) submittedBy String locationPIDs String? contactName String? @@ -58,15 +58,16 @@ model submission { financiallySupportedNonProfit Boolean? financiallySupportedHousingCoop Boolean? waitingOn String? - bringForwardDate DateTime? @db.Timestamptz(6) + bringForwardDate DateTime? @db.Timestamptz(6) notes String? intakeStatus String? applicationStatus String? - createdBy String? @default("00000000-0000-0000-0000-000000000000") - createdAt DateTime? @default(now()) @db.Timestamptz(6) + createdBy String? @default("00000000-0000-0000-0000-000000000000") + createdAt DateTime? @default(now()) @db.Timestamptz(6) updatedBy String? - updatedAt DateTime? @db.Timestamptz(6) - user user? @relation(fields: [assignedToUserId], references: [userId], onDelete: Cascade, map: "submission_assignedtouserid_foreign") + updatedAt DateTime? @db.Timestamptz(6) + document document[] + user user? @relation(fields: [assignedToUserId], references: [userId], onDelete: Cascade, map: "submission_assignedtouserid_foreign") } model user { @@ -90,3 +91,18 @@ model user { @@index([identityId], map: "user_identityid_index") @@index([username], map: "user_username_index") } + +model document { + documentId String @id @db.Uuid + submissionId String @db.Uuid + filename String + mimeType String @default("application/octet-stream") + filesize BigInt + createdBy String? @default("00000000-0000-0000-0000-000000000000") + createdAt DateTime? @default(now()) @db.Timestamptz(6) + updatedBy String? + updatedAt DateTime? @db.Timestamptz(6) + submission submission @relation(fields: [submissionId], references: [submissionId], onDelete: Cascade, map: "document_submissionid_foreign") + + @@unique([documentId, submissionId], map: "document_documentid_submissionid_unique") +} diff --git a/app/src/routes/v1/document.ts b/app/src/routes/v1/document.ts new file mode 100644 index 00000000..ce1c0eaf --- /dev/null +++ b/app/src/routes/v1/document.ts @@ -0,0 +1,18 @@ +import express from 'express'; +import { documentController } from '../../controllers'; +import { requireSomeAuth } from '../../middleware/requireSomeAuth'; + +import type { NextFunction, Request, Response } from '../../interfaces/IExpress'; + +const router = express.Router(); +router.use(requireSomeAuth); + +router.put('/', (req: Request, res: Response, next: NextFunction): void => { + documentController.createDocument(req, res, next); +}); + +router.get('/list/:submissionId', (req: Request, res: Response, next: NextFunction): void => { + documentController.listDocuments(req, res, next); +}); + +export default router; diff --git a/app/src/routes/v1/index.ts b/app/src/routes/v1/index.ts index cac356e3..f6d7496e 100644 --- a/app/src/routes/v1/index.ts +++ b/app/src/routes/v1/index.ts @@ -1,6 +1,7 @@ import { currentUser } from '../../middleware/authentication'; import express from 'express'; import chefs from './chefs'; +import document from './document'; import user from './user'; const router = express.Router(); @@ -9,12 +10,12 @@ router.use(currentUser); // Base v1 Responder router.get('/', (_req, res) => { res.status(200).json({ - endpoints: ['/chefs', '/user'] + endpoints: ['/chefs', '/document', '/user'] }); }); -/** CHEFS Router */ router.use('/chefs', chefs); +router.use('/document', document); router.use('/user', user); export default router; diff --git a/app/src/services/chefs.ts b/app/src/services/chefs.ts index 711fe6aa..cb6808c8 100644 --- a/app/src/services/chefs.ts +++ b/app/src/services/chefs.ts @@ -91,7 +91,7 @@ const service = { } }); - return submission.fromDBModel(result); + return submission.fromPrismaModel(result); } catch (e: unknown) { throw e; } @@ -125,13 +125,13 @@ const service = { } }); - return result.map((x) => submission.fromDBModel(x)); + return result.map((x) => submission.fromPrismaModel(x)); }, updateSubmission: async (data: ChefsSubmissionForm) => { try { await prisma.submission.update({ - data: submission.toDBModel(data), + data: submission.toPrismaModel(data), where: { submissionId: data.submissionId } diff --git a/app/src/services/document.ts b/app/src/services/document.ts new file mode 100644 index 00000000..1e7473f0 --- /dev/null +++ b/app/src/services/document.ts @@ -0,0 +1,49 @@ +import prisma from '../db/dataConnection'; +import { document } from '../db/models'; + +const service = { + /** + * @function createDocument + * Creates a link between a submission and a previously existing object in COMS + * @param documentId COMS ID of an existing object + * @param submissionId Submission ID the document is associated with + * @param filename Original filename of the document + * @param mimeType Type of document + * @param filesize Size of document + */ + createDocument: async ( + documentId: string, + submissionId: string, + filename: string, + mimeType: string, + filesize: number + ) => { + await prisma.document.create({ + data: { + documentId: documentId, + submissionId: submissionId, + filename: filename, + mimeType: mimeType, + filesize: filesize + } + }); + }, + + /** + * @function listDocuments + * Retrieve a list of documents associated with a given submission + * @param submissionId PCNS Submission ID + * @returns Array of documents associated with the submission + */ + listDocuments: async (submissionId: string) => { + const response = await prisma.document.findMany({ + where: { + submissionId: submissionId + } + }); + + return response.map((x) => document.fromPrismaModel(x)); + } +}; + +export default service; diff --git a/app/src/services/index.ts b/app/src/services/index.ts index 060b691d..968135c5 100644 --- a/app/src/services/index.ts +++ b/app/src/services/index.ts @@ -1,2 +1,3 @@ export { default as chefsService } from './chefs'; export { default as userService } from './user'; +export { default as documentService } from './document'; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index 370436db..1634558a 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -53,7 +53,7 @@ const service = { createdBy: NIL }; - const response = trxWrapper(etrx).identity_provider.create({ data: identity_provider.toDBModel(obj) }); + const response = trxWrapper(etrx).identity_provider.create({ data: identity_provider.toPrismaModel(obj) }); return response; }, @@ -100,7 +100,7 @@ const service = { }; response = await trx.user.create({ - data: user.toDBModel(newUser) + data: user.toPrismaModel(newUser) }); } }; @@ -193,7 +193,7 @@ const service = { } }); - return identity_provider.fromDBModel(response); + return identity_provider.fromPrismaModel(response); }, /** @@ -260,7 +260,7 @@ const service = { } }); - return response.map((x) => user.fromDBModel(x)); + return response.map((x) => user.fromPrismaModel(x)); }, /** @@ -301,7 +301,7 @@ const service = { // TODO: Add support for updating userId primary key in the event it changes response = await trx?.user.update({ - data: user.toDBModel(obj), + data: user.toPrismaModel(obj), where: { userId: userId } diff --git a/app/src/types/Document.ts b/app/src/types/Document.ts new file mode 100644 index 00000000..9ac83f11 --- /dev/null +++ b/app/src/types/Document.ts @@ -0,0 +1,9 @@ +import { IStamps } from '../interfaces/IStamps'; + +export type Document = { + documentId: string; // Primary Key + submissionId: string; + filename: string; + mimeType: string; + filesize: number; +} & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 89895d22..3fdb273f 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -2,6 +2,7 @@ export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionForm } from './ChefsSubmissionForm'; export type { ChefsSubmissionFormExport } from './ChefsSubmissionFormExport'; export type { CurrentUser } from './CurrentUser'; +export type { Document } from './Document'; export type { IdentityProvider } from './IdentityProvider'; export type { SubmissionSearchParameters } from './SubmissionSearchParameters'; export type { User } from './User'; diff --git a/charts/pcns/Chart.yaml b/charts/pcns/Chart.yaml index 1bba1a86..4779c2c1 100644 --- a/charts/pcns/Chart.yaml +++ b/charts/pcns/Chart.yaml @@ -3,7 +3,7 @@ name: nr-permitconnect-navigator-service # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.1 +version: 0.0.2 kubeVersion: ">= 1.13.0" description: PermitConnect Navigator Service # A chart can be either an 'application' or a 'library' chart. diff --git a/charts/pcns/README.md b/charts/pcns/README.md index 302477ee..b9404948 100644 --- a/charts/pcns/README.md +++ b/charts/pcns/README.md @@ -1,6 +1,6 @@ # nr-permitconnect-navigator-service -![Version: 0.0.1](https://img.shields.io/badge/Version-0.0.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.0](https://img.shields.io/badge/AppVersion-0.1.0-informational?style=flat-square) +![Version: 0.0.2](https://img.shields.io/badge/Version-0.0.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.1.0](https://img.shields.io/badge/AppVersion-0.1.0-informational?style=flat-square) PermitConnect Navigator Service @@ -33,7 +33,7 @@ Kubernetes: `>= 1.13.0` | autoscaling.maxReplicas | int | `16` | | | autoscaling.minReplicas | int | `2` | | | autoscaling.targetCPUUtilizationPercentage | int | `80` | | -| config.configMap | object | `{"FRONTEND_APIPATH":"api/v1","FRONTEND_COMS_APIPATH":null,"FRONTEND_OIDC_AUTHORITY":null,"FRONTEND_OIDC_CLIENTID":null,"SERVER_APIPATH":"/api/v1","SERVER_BODYLIMIT":"30mb","SERVER_CHEFS_APIPATH":null,"SERVER_DB_HOST":null,"SERVER_DB_POOL_MAX":"10","SERVER_DB_POOL_MIN":"2","SERVER_DB_PORT":"5432","SERVER_LOGLEVEL":"http","SERVER_OIDC_AUTHORITY":null,"SERVER_OIDC_IDENTITYKEY":null,"SERVER_OIDC_PUBLICKEY":null,"SERVER_PORT":"8080"}` | These values will be wholesale added to the configmap as is; refer to the pcns documentation for what each of these values mean and whether you need them defined. Ensure that all values are represented explicitly as strings, as non-string values will not translate over as expected into container environment variables. For configuration keys named `*_ENABLED`, either leave them commented/undefined, or set them to string value "true". | +| config.configMap | object | `{"FRONTEND_APIPATH":"api/v1","FRONTEND_COMS_APIPATH":null,"FRONTEND_COMS_BUCKETID":null,"FRONTEND_OIDC_AUTHORITY":null,"FRONTEND_OIDC_CLIENTID":null,"SERVER_APIPATH":"/api/v1","SERVER_BODYLIMIT":"30mb","SERVER_CHEFS_APIPATH":null,"SERVER_DB_HOST":null,"SERVER_DB_POOL_MAX":"10","SERVER_DB_POOL_MIN":"2","SERVER_DB_PORT":"5432","SERVER_LOGLEVEL":"http","SERVER_OIDC_AUTHORITY":null,"SERVER_OIDC_IDENTITYKEY":null,"SERVER_OIDC_PUBLICKEY":null,"SERVER_PORT":"8080"}` | These values will be wholesale added to the configmap as is; refer to the pcns documentation for what each of these values mean and whether you need them defined. Ensure that all values are represented explicitly as strings, as non-string values will not translate over as expected into container environment variables. For configuration keys named `*_ENABLED`, either leave them commented/undefined, or set them to string value "true". | | config.enabled | bool | `false` | Set to true if you want to let Helm manage and overwrite your configmaps. | | config.releaseScoped | bool | `false` | This should be set to true if and only if you require configmaps and secrets to be release scoped. In the event you want all instances in the same namespace to share a similar configuration, this should be set to false | | dbSecretOverride.password | string | `nil` | | diff --git a/charts/pcns/values.yaml b/charts/pcns/values.yaml index a8e7df31..5f6b108e 100644 --- a/charts/pcns/values.yaml +++ b/charts/pcns/values.yaml @@ -133,6 +133,7 @@ config: configMap: FRONTEND_APIPATH: api/v1 FRONTEND_COMS_APIPATH: ~ + FRONTEND_COMS_BUCKETID: ~ FRONTEND_OIDC_AUTHORITY: ~ FRONTEND_OIDC_CLIENTID: ~ diff --git a/frontend/src/services/comsService.ts b/frontend/src/services/comsService.ts new file mode 100644 index 00000000..acf3f1cc --- /dev/null +++ b/frontend/src/services/comsService.ts @@ -0,0 +1,58 @@ +import { comsAxios } from './interceptors'; +import { setDispositionHeader } from '@/utils/utils'; + +import type { AxiosRequestConfig } from 'axios'; + +const PATH = '/object'; + +export default { + /** + * @function createObject + * Post an object + * @param {any} object Object to be created + * @param {string} bucketId Bucket id containing the object + * @param {AxiosRequestConfig} axiosOptions Axios request config options + * @returns {Promise} An axios response + */ + async createObject( + object: any, + headers: { + metadata?: Array<{ key: string; value: string }>; + }, + params: { + bucketId?: string; + tagset?: Array<{ key: string; value: string }>; + }, + axiosOptions?: AxiosRequestConfig + ) { + // setDispositionHeader constructs header based on file name + // Content-Type defaults octet-stream if MIME type unavailable + const config = { + headers: { + 'Content-Disposition': setDispositionHeader(object.name), + 'Content-Type': object?.type ?? 'application/octet-stream' + }, + params: { + bucketId: params.bucketId, + tagset: {} + } + }; + + // Map the metadata if required + if (headers.metadata) { + config.headers = { + ...config.headers, + ...Object.fromEntries(headers.metadata.map((x: { key: string; value: string }) => [x.key, x.value])) + }; + } + + // Map the tagset if required + if (params.tagset) { + config.params.tagset = Object.fromEntries( + params.tagset.map((x: { key: string; value: string }) => [x.key, x.value]) + ); + } + + return comsAxios(axiosOptions).put(PATH, object, config); + } +}; diff --git a/frontend/src/services/documentService.ts b/frontend/src/services/documentService.ts new file mode 100644 index 00000000..9db46de7 --- /dev/null +++ b/frontend/src/services/documentService.ts @@ -0,0 +1,36 @@ +import comsService from './comsService'; +import { appAxios } from './interceptors'; + +const PATH = '/document'; + +export default { + /** + * @function createDocument + * @returns {Promise} An axios response + */ + async createDocument(file: File, submissionId: string, bucketId: string) { + let comsResponse; + try { + comsResponse = await comsService.createObject( + file, + {}, + { bucketId }, + { timeout: 0 } // Infinite timeout for big files upload to avoid timeout error + ); + + return appAxios().put(PATH, { + submissionId: submissionId, + documentId: comsResponse.data.id, + filename: comsResponse.data.name, + mimeType: comsResponse.data.mimeType, + length: comsResponse.data.length + }); + } catch (e) { + // TODO: Delete object if Prisma write fails + } + }, + + async listDocuments(submissionId: string) { + return appAxios().get(`${PATH}/list/${submissionId}`); + } +}; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 8d5a59e1..bbba58fe 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,4 +1,6 @@ export { default as AuthService } from './authService'; export { default as ConfigService } from './configService'; export { default as chefsService } from './chefsService'; +export { default as comsService } from './comsService'; +export { default as documentService } from './documentService'; export { default as userService } from './userService'; diff --git a/frontend/src/services/interceptors.ts b/frontend/src/services/interceptors.ts index 99226cb9..8836f9f1 100644 --- a/frontend/src/services/interceptors.ts +++ b/frontend/src/services/interceptors.ts @@ -33,3 +33,33 @@ export function appAxios(options: AxiosRequestConfig = {}): AxiosInstance { return instance; } + +/** + * @function comsAxios + * Returns an Axios instance for the COMS API + * @param {AxiosRequestConfig} options Axios request config options + * @returns {AxiosInstance} An axios instance + */ +export function comsAxios(options: AxiosRequestConfig = {}): AxiosInstance { + const instance = axios.create({ + baseURL: new ConfigService().getConfig().coms.apiPath, + timeout: 10000, + ...options + }); + + instance.interceptors.request.use( + async (cfg: InternalAxiosRequestConfig) => { + const authService = new AuthService(); + const user = await authService.getUser(); + if (!!user && !user.expired) { + cfg.headers.Authorization = `Bearer ${user.access_token}`; + } + return Promise.resolve(cfg); + }, + (error: Error) => { + return Promise.reject(error); + } + ); + + return instance; +} diff --git a/frontend/src/views/SubmissionView.vue b/frontend/src/views/SubmissionView.vue index b3f31239..922f8a34 100644 --- a/frontend/src/views/SubmissionView.vue +++ b/frontend/src/views/SubmissionView.vue @@ -1,9 +1,11 @@