diff --git a/.env.production.example b/.env.production.example index e9c8737..29bf5a0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -22,5 +22,5 @@ SHLP_POSTGRES_DB= SHLP_POSTGRES_USERNAME= SHLP_POSTGRES_PASSWORD= POSTGRES_PRISMA_URL=postgresql://${SHLP_POSTGRES_USERNAME}:${SHLP_POSTGRES_PASSWORD}@${SHLP_POSTGRES_HOST}:5432/${SHLP_POSTGRES_DB} -PRISMA_FIELD_ENCRYPTION_KEY= -PRISMA_FIELD_ENCRYPTION_HASH_SALT= +PRISMA_FIELD_ENCRYPTION_KEY=k1.aesgcm256.Wmy6koJaJpjB0H25qBccLfQ84AvpmCjexanqCHJ5Hr0= +PRISMA_FIELD_ENCRYPTION_HASH_SALT=1a4671c31ff22834a56d4f3741aeac8cc8662a11c73802083ea5a118e5ee600e diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 6934e80..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Tests with Coverage Check - -on: - push: - branches: - # - '*' - - 'main' - pull_request: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup Node - uses: actions/setup-node@v3 - with: - node-version: '18' - cache: 'yarn' - - - name: Install Dependencies - run: yarn install --frozen-lockfile - - - name: Run Jest Tests - run: yarn test --collectCoverage=false - - # coverage: - # runs-on: ubuntu-latest - # needs: test - # steps: - # # - name: For act to work (local testing) - # # run: npm -g install yarn - - # - uses: actions/checkout@v3 - # with: - # fetch-depth: 0 # Fetch all history for accurate comparison - - # - name: Setup Node - # uses: actions/setup-node@v3 - # with: - # node-version: '18' - # cache: 'yarn' - - # - name: Install Dependencies - # run: yarn install --frozen-lockfile - - # - name: Checkout Main Branch - # uses: actions/checkout@v3 - # with: - # ref: main - # path: main - - # - name: Temp - Install Jest - # run: yarn add jest - # - name: Run Jest Coverage for Main - # working-directory: main - # run: yarn test --coverage - - # - name: Run Jest Coverage for Current Branch - # run: yarn test --coverage - - # - name: Get Coverage Difference - # id: coverage-difference - # run: | - # if [[ -f ./coverage/lcov.info ]] && [[ -f ./main/coverage/lcov.info ]]; then - # echo "Current Coverage:" - # currentCoverage=$(cat ./coverage/lcov.info | yarn jest-coverage-badges -r lcov | grep -oP '(?<=coverage: )\d+') - # echo "$currentCoverage" - - # echo "Main Coverage:" - # mainCoverage=$(cat ./main/coverage/lcov.info | yarn jest-coverage-badges -r lcov | grep -oP '(?<=coverage: )\d+') - # echo "$mainCoverage" - - # diff=$(echo "$mainCoverage" "$currentCoverage" | awk '{print ($2-$1)}') - # echo "::set-output name=diff::$diff" - # else - # echo "One or both coverage reports are missing. Skipping comparison." - # echo "::set-output name=diff::0" # Treat missing reports as no change - # fi - - # - name: Fail if Coverage Decreased - # if: steps.coverage-difference.outputs.diff < 0 - # run: | - # echo "🛑 Coverage decreased compared to main!" - # exit 1 - - # - name: Upload Coverage to Codecov - # uses: codecov/codecov-action@v3 - # with: - # token: ${{ secrets.CODECOV_TOKEN }} - - # - name: Coverage Summary - # run: | - # echo "✅ Jest coverage passed with:" - # echo "$(cat ./coverage/lcov.info | yarn jest-coverage-badges -r lcov)" - # echo "View detailed coverage report on Codecov." diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/tests-build-push.yml similarity index 59% rename from .github/workflows/docker-publish.yml rename to .github/workflows/tests-build-push.yml index 6e2f2ab..3fb1fc1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/tests-build-push.yml @@ -1,16 +1,33 @@ -name: Build and Push Smart Health Links Portal Image +name: Test - Build - Push on: - workflow_run: - workflows: ['Tests with Coverage Check'] - types: [completed] + push: branches: - 'main' + pull_request: + branches: + - main jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Run Jest Tests + run: yarn test --collectCoverage=false + build-and-push: runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -21,21 +38,21 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push tag + - name: Test Docker build if: ${{ github.ref_name != 'main' }} uses: docker/build-push-action@v5 with: - platforms: linux/amd64,linux/arm64 - push: true + platforms: linux/amd64 + push: false # dont push image - Used to confirm build success on branches file: docker/production/Dockerfile - tags: jembi/smart-health-links-portal:${{ github.ref_name }} + - name: Login to DockerHub + if: ${{ github.ref_name == 'main' }} + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push latest if: ${{ github.ref_name == 'main' }} uses: docker/build-push-action@v5 diff --git a/.gitignore b/.gitignore index a010c28..a4b6e41 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ next-env.d.ts # db dev.db dev.db-journal + +#logs +/logs \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 32344ab..d816fe3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,4 +67,4 @@ volumes: networks: smart-health-links-portal-network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 48e575f..190dd9b 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine AS base # 1. Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +RUN apk add --update --no-cache libc6-compat python3 make g++ && rm -rf /var/cache/apk/* WORKDIR /app @@ -20,7 +20,9 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # This will do the trick, use the corresponding env file for each environment. COPY .env.production.example .env.production -RUN npm run build + +RUN yarn build && \ + yarn next-swagger-doc-cli next-swagger-doc.json # 3. Production image, copy all the files and run next FROM base AS runner @@ -37,13 +39,18 @@ COPY --from=builder /app/public ./public # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +# Ensure logs directory is created under the correct permissions +RUN mkdir logs && \ + chmod 755 logs && \ + chown nextjs:nodejs logs USER nextjs EXPOSE 3000 -ENV PORT 3000 -ENV HOSTNAME "0.0.0.0" +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] \ No newline at end of file +CMD ["sh", "-c", "npx --yes prisma migrate deploy --schema=./prisma/schema.prisma && node server.js"] diff --git a/logger.config.json b/logger.config.json new file mode 100644 index 0000000..a92289c --- /dev/null +++ b/logger.config.json @@ -0,0 +1,15 @@ +{ + "transporters": [ + { + "transporterType": "file", + "options": { + "filename": "logs/logs-%DATE%.log", + "datePattern": "YYYY-MM-DD", + "zippedArchive": true, + "maxSize": "20m", + "maxFiles": "14d" + } + } + ], + "format": ["timestamp", "json"] +} \ No newline at end of file diff --git a/package.json b/package.json index f9e0a8f..359707f 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", + "winston-daily-rotate-file": "^5.0.0", "swagger-ui-react": "^5.17.14", "uuid": "^10.0.0", "zod": "^3.23.8" @@ -45,6 +46,7 @@ "@types/node": "^22.0.3", "@types/react": "^18", "@types/react-dom": "^18", + "@types/winston": "^2.4.4", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.4.0", "eslint": "^8.57.0", diff --git a/src/app/api/auth/authOptions.ts b/src/app/api/auth/authOptions.ts index 6c40b43..7e68cbf 100644 --- a/src/app/api/auth/authOptions.ts +++ b/src/app/api/auth/authOptions.ts @@ -21,7 +21,22 @@ export const authOptions: AuthOptions = { ], callbacks: { session: async ({ session, token }) => { - return { ...session, user: token.user }; + return { ...session, ...token }; + }, + jwt: (params) => { + const tokenParam = params['token']; + if (tokenParam?.['token']) { + const { user = {}, token = {}, account = {} } = tokenParam; + + return { + user, + token, + account: { + access_token: account['access_token'], + }, + }; + } + return params; }, }, }; diff --git a/src/app/api/route.ts b/src/app/api/route.ts index 90d5f17..3516a3d 100644 --- a/src/app/api/route.ts +++ b/src/app/api/route.ts @@ -1,5 +1,10 @@ -import { NextResponse, NextRequest } from 'next/server'; +import { NextResponse, NextRequest } from "next/server"; + +import { LogHandler } from "@/lib/logger"; + +const logger = new LogHandler(__dirname) export async function GET(request) { - return NextResponse.json({ message: 'API Health Check' }, { status: 200 }); + logger.info("API connected successfully"); + return NextResponse.json({ message: "API Health Check" }, { status: 200 }); } diff --git a/src/app/api/v1/server-configs/route.test.ts b/src/app/api/v1/server-configs/route.test.ts index d4068a4..06e90fb 100644 --- a/src/app/api/v1/server-configs/route.test.ts +++ b/src/app/api/v1/server-configs/route.test.ts @@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { POST, GET } from '@/app/api/v1/server-configs/route'; +import { validateUserRoles } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { CreateServerConfigDto, @@ -14,6 +15,10 @@ import { mapDtoToModel, mapModelToDto } from '@/mappers/server-config-mapper'; import { addServerConfigUseCase } from '@/usecases/server-configs/add-server-config'; import { getServerConfigsUseCase } from '@/usecases/server-configs/get-server-configs'; +jest.mock('@/app/utils/authentication', () => ({ + validateUserRoles: jest.fn(), +})); + jest.mock('@/usecases/server-configs/add-server-config', () => ({ addServerConfigUseCase: jest.fn(), })); @@ -53,6 +58,8 @@ describe('POST /api/v1/server-configs', () => { endpointUrl: 'https://dto-endpoint-url.com', }; + const mockRoute = '/api/v1/server-configs'; + const mockRequest = (body: any) => new NextRequest('http://localhost/api/v1/server-configs', { method: 'POST', @@ -65,6 +72,7 @@ describe('POST /api/v1/server-configs', () => { it('should return server config DTO and status 201 when server config is successfully created', async () => { (mapDtoToModel as jest.Mock).mockReturnValue(mockServerConfigModel); + (validateUserRoles as jest.Mock).mockResolvedValue(true); (addServerConfigUseCase as jest.Mock).mockResolvedValue( mockServerConfigModel, ); @@ -83,6 +91,7 @@ describe('POST /api/v1/server-configs', () => { it('should handle validation errors and return status 422', async () => { const error = new Error('Validation error'); (addServerConfigUseCase as jest.Mock).mockRejectedValue(error); + (validateUserRoles as jest.Mock).mockResolvedValue(true); (handleApiValidationError as jest.Mock).mockReturnValue( NextResponse.json({ message: 'Validation error' }, { status: 422 }), ); @@ -148,6 +157,7 @@ describe('GET /api/v1/server-configs', () => { (getServerConfigsUseCase as jest.Mock).mockResolvedValue([ mockServerConfigModel, ]); + (validateUserRoles as jest.Mock).mockResolvedValue(true); (mapModelToDto as jest.Mock).mockReturnValue(mockServerConfigDto); const request = mockRequest(); diff --git a/src/app/api/v1/server-configs/route.ts b/src/app/api/v1/server-configs/route.ts index 94c6dda..c547d28 100644 --- a/src/app/api/v1/server-configs/route.ts +++ b/src/app/api/v1/server-configs/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; +import { validateUserRoles } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, ServerConfigRepositoryToken } from '@/container'; import { @@ -7,6 +8,7 @@ import { ServerConfigDto, } from '@/domain/dtos/server-config'; import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; +import { LogHandler } from '@/lib/logger'; import { mapDtoToModel, mapModelToDto } from '@/mappers/server-config-mapper'; import { addServerConfigUseCase } from '@/usecases/server-configs/add-server-config'; import { getServerConfigsUseCase } from '@/usecases/server-configs/get-server-configs'; @@ -14,6 +16,7 @@ import { getServerConfigsUseCase } from '@/usecases/server-configs/get-server-co const repo = container.get( ServerConfigRepositoryToken, ); +const logger = new LogHandler(__dirname); /** * @swagger @@ -38,7 +41,9 @@ const repo = container.get( */ export async function POST(request: Request) { let dto: CreateServerConfigDto = await request.json(); + logger.info(`Creating server config API with request: ${JSON.stringify(dto)}`); try { + await validateUserRoles(request, 'admin'); const model = mapDtoToModel(dto as ServerConfigDto); const newServerConfig = await addServerConfigUseCase( { repo }, @@ -46,7 +51,7 @@ export async function POST(request: Request) { ); return NextResponse.json(mapModelToDto(newServerConfig), { status: 201 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } @@ -66,9 +71,15 @@ export async function POST(request: Request) { * $ref: '#/components/schemas/ServerConfig' */ export async function GET(request: Request) { + try{ + logger.info(`Getting all available server configs data`); + await validateUserRoles(request, 'admin'); const serverConfigs = await getServerConfigsUseCase({ repo }); return NextResponse.json( serverConfigs.map((x) => mapModelToDto(x)), { status: 200 }, ); +} catch (error) { + return handleApiValidationError(error, logger); + } } diff --git a/src/app/api/v1/share-links/[id]/accesses/route.test.ts b/src/app/api/v1/share-links/[id]/accesses/route.test.ts index b18e7e0..3a9180a 100644 --- a/src/app/api/v1/share-links/[id]/accesses/route.test.ts +++ b/src/app/api/v1/share-links/[id]/accesses/route.test.ts @@ -34,6 +34,8 @@ jest.mock('@/app/utils/error-handler', () => ({ handleApiValidationError: jest.fn(), })); +const mockRoute = '/api/v1/share-links/{id}/accesses'; + describe('POST function', () => { let mockGetSingleSHLinkUseCase: jest.Mock; let mockGetSHLinkAccessesUseCase: jest.Mock; diff --git a/src/app/api/v1/share-links/[id]/accesses/route.ts b/src/app/api/v1/share-links/[id]/accesses/route.ts index 9c7ac45..8bc1abe 100644 --- a/src/app/api/v1/share-links/[id]/accesses/route.ts +++ b/src/app/api/v1/share-links/[id]/accesses/route.ts @@ -10,6 +10,7 @@ import { import { SHLinkAccessRequestDto } from '@/domain/dtos/shlink-access'; import { ISHLinkAccessRepository } from '@/infrastructure/repositories/interfaces/shlink-access-repository'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { mapModelToDto } from '@/mappers/shlink-access-mapper'; import { getSHLinkAccessesUseCase } from '@/usecases/shlink-access/get-shlink-accesses'; import { getSingleSHLinkUseCase } from '@/usecases/shlinks/get-single-shlink'; @@ -19,6 +20,8 @@ const repo = container.get( ); const shlinkRepo = container.get(SHLinkRepositoryToken); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links/{id}/accesses: @@ -49,6 +52,8 @@ const shlinkRepo = container.get(SHLinkRepositoryToken); export async function POST(request: Request, params: { id: string }) { try { const { managementToken }: SHLinkAccessRequestDto = await request.json(); + logger.info(`Getting share link access for a user with share link id: ${params.id} and management token: ${managementToken}`); + const shlink = await getSingleSHLinkUseCase( { repo: shlinkRepo }, { id: params.id, managementToken: managementToken }, @@ -66,6 +71,6 @@ export async function POST(request: Request, params: { id: string }) { { status: 200 }, ); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/[id]/deactivate/route.ts b/src/app/api/v1/share-links/[id]/deactivate/route.ts index ca54fc6..b03bf98 100644 --- a/src/app/api/v1/share-links/[id]/deactivate/route.ts +++ b/src/app/api/v1/share-links/[id]/deactivate/route.ts @@ -5,11 +5,14 @@ import { getUserProfile } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, SHLinkRepositoryToken } from '@/container'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { mapModelToDto } from '@/mappers/shlink-mapper'; import { deactivateSHLinksUseCase } from '@/usecases/shlinks/deactivate-shlink'; const repo = container.get(SHLinkRepositoryToken); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links/{id}/deactivate: @@ -34,6 +37,7 @@ export async function DELETE( request: Request, { params }: { params: { id: string } }, ) { + logger.info(`Deactivating a share link API with share link id: ${params.id}`); try { const user = await getUserProfile(request); const result = await deactivateSHLinksUseCase( @@ -44,6 +48,6 @@ export async function DELETE( if (result) return NextResponse.json(data, { status: 200 }); return NextResponse.json({ message: NOT_FOUND }, { status: 404 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.test.ts b/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.test.ts index 615a342..9253639 100644 --- a/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.test.ts +++ b/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.test.ts @@ -38,6 +38,8 @@ describe('GET /api/v1/share-links/[id]/endpoints/[endpointId]', () => { ticket: '123456789', }; + const mockRoute = '/api/v1/share-links/{id}/endpoints/{endpointId}'; + const mockTicket = new AccessTicketModel('abc', 'ticket-123'); const mockShlink = new SHLinkModel( 'user-123456', diff --git a/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.ts b/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.ts index 7b5ed94..7ae0050 100644 --- a/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.ts +++ b/src/app/api/v1/share-links/[id]/endpoints/[endpointId]/route.ts @@ -17,6 +17,7 @@ import { IAccessTicketRepository } from '@/infrastructure/repositories/interface import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; import { IUserRepository } from '@/infrastructure/repositories/interfaces/user-repository'; +import { LogHandler } from '@/lib/logger'; import { getAccessTicketUseCase } from '@/usecases/access-tickets/get-access-ticket'; import { getPatientDataUseCase } from '@/usecases/patient/get-patient-data'; import { getSingleSHLinkUseCase } from '@/usecases/shlinks/get-single-shlink'; @@ -31,6 +32,8 @@ const serverConfigRepo = container.get( ServerConfigRepositoryToken, ); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links/{id}/endpoints/{endpointId}: @@ -61,6 +64,7 @@ export async function GET( const url = new URL(request.url); const ticketId = url.searchParams.get('ticket'); + logger.info(`Getting an endpoint data with share link id: ${params.id}, endpoint id: ${params.endpointId} and ticket id: ${ticketId}`); try { const ticket: AccessTicketModel = await getAccessTicketUseCase( @@ -87,7 +91,7 @@ export async function GET( { repo: userRepo }, { userId: shlink.getUserId() }, ); - + logger.info(`Getting an endpoint data of user id: ${shlink.getUserId()} with share link id: ${params.id}, endpoint id: ${params.endpointId} and ticket id: ${ticketId}`); const patient = await getPatientDataUseCase( { repo: serverConfigRepo }, { user: user }, @@ -95,6 +99,6 @@ export async function GET( return NextResponse.json(patient, { status: 200 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/[id]/endpoints/route.test.ts b/src/app/api/v1/share-links/[id]/endpoints/route.test.ts index 992debd..7fc787a 100644 --- a/src/app/api/v1/share-links/[id]/endpoints/route.test.ts +++ b/src/app/api/v1/share-links/[id]/endpoints/route.test.ts @@ -32,6 +32,8 @@ describe('POST /api/v1/shlinks/[id]/endpoint', () => { const mockEndpointId = 'endpoint-67890'; const mockManagementToken = 'token-xyz12345'; + const mockRoute = '/api/v1/share-links/{id}/endpoints'; + const mockRequestBody = { url_path: mockUrlPath, management_token: mockManagementToken, @@ -144,7 +146,7 @@ describe('POST /api/v1/shlinks/[id]/endpoint', () => { const response = await POST(mockRequest, { params: { id: mockShlinkId } }); - expect(handleApiValidationError).toHaveBeenCalledWith(mockError); + expect(handleApiValidationError).toHaveBeenCalledWith(mockError, expect.anything()); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(400); diff --git a/src/app/api/v1/share-links/[id]/endpoints/route.ts b/src/app/api/v1/share-links/[id]/endpoints/route.ts index 5a0eb12..a3a3263 100644 --- a/src/app/api/v1/share-links/[id]/endpoints/route.ts +++ b/src/app/api/v1/share-links/[id]/endpoints/route.ts @@ -15,6 +15,7 @@ import { import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { ISHLinkEndpointRepository } from '@/infrastructure/repositories/interfaces/shlink-endpoint-repository'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { mapDtoToModel, mapModelToDto as mapModelToDtoEndpoint, @@ -32,6 +33,8 @@ const serverConfigRepo = container.get( ServerConfigRepositoryToken, ); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links/{id}/endpoints: @@ -63,6 +66,7 @@ export async function POST( { params }: { params: { id: string } }, ) { let dto: CreateSHLinkEndpointDto = await request.json(); + logger.info(`Creating a share link endpoint with parameters: ${JSON.stringify(dto)}`); try { const serverConfig = ( @@ -81,6 +85,7 @@ export async function POST( const shlinkData = mapShlinkModelToDto(shl); dto.serverConfigId = serverConfig.getId(); dto.shlinkId = shlinkData.id; + const endpoint = mapDtoToModel(dto as SHLinkEndpointDto); const endpointResult = await addEndpointUseCase( { repo: shlEndpointRepo }, @@ -91,6 +96,6 @@ export async function POST( status: 200, }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/[id]/qrcode/route.test.ts b/src/app/api/v1/share-links/[id]/qrcode/route.test.ts index 5dfd1b8..8fc2ba6 100644 --- a/src/app/api/v1/share-links/[id]/qrcode/route.test.ts +++ b/src/app/api/v1/share-links/[id]/qrcode/route.test.ts @@ -33,6 +33,8 @@ describe('POST /api/v1/shlinks/{id}/qrcode', () => { const mockParams = { id: '123' }; + const mockRoute = '/api/v1/share-links/{id}/qrcode'; + const mockRequest = { json: jest.fn().mockResolvedValue(mockRequestDto), } as unknown as Request; @@ -79,6 +81,6 @@ describe('POST /api/v1/shlinks/{id}/qrcode', () => { await POST(mockRequest, { params: mockParams }); - expect(handleApiValidationError).toHaveBeenCalledWith(mockError); + expect(handleApiValidationError).toHaveBeenCalledWith(mockError, expect.anything()); }); }); diff --git a/src/app/api/v1/share-links/[id]/qrcode/route.ts b/src/app/api/v1/share-links/[id]/qrcode/route.ts index cece321..af4c0ba 100644 --- a/src/app/api/v1/share-links/[id]/qrcode/route.ts +++ b/src/app/api/v1/share-links/[id]/qrcode/route.ts @@ -5,11 +5,15 @@ import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, SHLinkRepositoryToken } from '@/container'; import { SHLinkQRCodeRequestDto } from '@/domain/dtos/shlink-qrcode'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { getSHLinkQRCodeUseCase } from '@/usecases/shlink-qrcode/get-shlink-qrcode'; import { getSingleSHLinkUseCase } from '@/usecases/shlinks/get-single-shlink'; const shlinkRepo = container.get(SHLinkRepositoryToken); +const logger = new LogHandler(__dirname); + + /** * @swagger * /api/v1/share-links/{id}/qrcode: @@ -39,12 +43,13 @@ const shlinkRepo = container.get(SHLinkRepositoryToken); */ export async function POST( - request: Request, - { params }: { params: { id: string } }, + request: Request, + { params }: {params: { id: string } }, ) { try { const { managementToken }: SHLinkQRCodeRequestDto = await request.json(); const { id } = params; + logger.info(`Creating a QR Code with share link id: ${id} and management token: ${managementToken}`); let shlink = await getSingleSHLinkUseCase( { repo: shlinkRepo }, @@ -63,6 +68,6 @@ export async function POST( }, }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/[id]/route.test.ts b/src/app/api/v1/share-links/[id]/route.test.ts index 358df29..5a2b0b9 100644 --- a/src/app/api/v1/share-links/[id]/route.test.ts +++ b/src/app/api/v1/share-links/[id]/route.test.ts @@ -74,6 +74,8 @@ jest.mock('next/server', () => ({ }, })); +const mockRoute = '/api/v1/share-links/{id}' + describe('POST handler', () => { const mockResponseJson = NextResponse.json as jest.Mock; const mockHandleApiValidationError = handleApiValidationError as jest.Mock; @@ -222,7 +224,7 @@ describe('POST handler', () => { const response = await POST(request, { params }); - expect(mockHandleApiValidationError).toHaveBeenCalledWith(error); + expect(mockHandleApiValidationError).toHaveBeenCalledWith(error, expect.anything()); expect(mockResponseJson).not.toHaveBeenCalled(); }); }); @@ -335,7 +337,7 @@ describe('PUT handler', () => { const response = await PUT(request, { params }); - expect(mockHandleApiValidationError).toHaveBeenCalledWith(error); + expect(mockHandleApiValidationError).toHaveBeenCalledWith(error, expect.anything()); expect(mockResponseJson).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/v1/share-links/[id]/route.ts b/src/app/api/v1/share-links/[id]/route.ts index 315815b..ab002e4 100644 --- a/src/app/api/v1/share-links/[id]/route.ts +++ b/src/app/api/v1/share-links/[id]/route.ts @@ -21,6 +21,7 @@ import { IAccessTicketRepository } from '@/infrastructure/repositories/interface import { ISHLinkAccessRepository } from '@/infrastructure/repositories/interfaces/shlink-access-repository'; import { ISHLinkEndpointRepository } from '@/infrastructure/repositories/interfaces/shlink-endpoint-repository'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { mapModelToMiniDto, mapModelToDto } from '@/mappers/shlink-mapper'; import { addAccessTicketUseCase } from '@/usecases/access-tickets/add-access-ticket'; import { deleteAccessTicketUseCase } from '@/usecases/access-tickets/delete-access-ticket'; @@ -50,6 +51,8 @@ const getPasswordErrorMessage = (shlink: SHLinkModel): string => { ); }; +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links/{id}: @@ -82,6 +85,7 @@ export async function POST( ) { let { managementToken, passcode, recipient }: SHLinkRequestDto = await request.json(); + logger.info(`Creating share link access with share link id: ${params.id} and parameters: ${JSON.stringify({ managementToken, recipient })}`); try { let shlink = await getSingleSHLinkUseCase( { repo }, @@ -102,11 +106,14 @@ export async function POST( { repo: accessRepo }, new SHLinkAccessModel(shlink.getId(), new Date(), recipient), ); + + logger.info(`Creating a share link access ticket with share link id: ${params.id}`); const ticket = await addAccessTicketUseCase( { repo: ticketRepo }, new AccessTicketModel(shlink.getId()), ); setTimeout(() => { + logger.info(`Deleting share link access ticket with ticket: ${JSON.stringify(ticket)}`); deleteAccessTicketUseCase({ repo: ticketRepo }, { id: ticket.getId() }); }, DELETE_DELAY); const endpoint = await getEndpointUseCase( @@ -118,7 +125,7 @@ export async function POST( { status: 200 }, ); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } @@ -154,7 +161,7 @@ export async function PUT( ) { let { managementToken, oldPasscode, passcode, expiryDate }: SHLinkUpdateDto = await request.json(); - + logger.info(`Updating a share link passcode and expiry date API with share link id: ${params.id} and parameters: ${JSON.stringify({managementToken, expiryDate})}`); try { let shlink = await getSingleSHLinkUseCase( { repo }, @@ -188,6 +195,6 @@ export async function PUT( if (updateShlink) return NextResponse.json(mapModelToDto(updateShlink), { status: 200 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/share-links/route.test.ts b/src/app/api/v1/share-links/route.test.ts index 9dfee5a..bd1552a 100644 --- a/src/app/api/v1/share-links/route.test.ts +++ b/src/app/api/v1/share-links/route.test.ts @@ -46,6 +46,8 @@ jest.mock('@/app/constants/http-constants', () => ({ })); // Constants +const mockRoute = '/api/v1/share-links' + const dataDto = { userId: '1234567890', name: 'name', @@ -132,7 +134,7 @@ describe('API Route Handlers', () => { const response = await POST(request); expect(validateUser).toHaveBeenCalledWith(request, mockDto.userId); - expect(handleApiValidationError).toHaveBeenCalledWith(error); + expect(handleApiValidationError).toHaveBeenCalledWith(error, expect.anything()); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(400); @@ -187,7 +189,7 @@ describe('API Route Handlers', () => { const response = await GET(request); expect(getUserProfile).toHaveBeenCalledWith(request); - expect(handleApiValidationError).toHaveBeenCalledWith(error); + expect(handleApiValidationError).toHaveBeenCalledWith(error, expect.anything()); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(500); diff --git a/src/app/api/v1/share-links/route.ts b/src/app/api/v1/share-links/route.ts index 86e4eb9..2a67c0a 100644 --- a/src/app/api/v1/share-links/route.ts +++ b/src/app/api/v1/share-links/route.ts @@ -5,6 +5,7 @@ import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, SHLinkRepositoryToken } from '@/container'; import { CreateSHLinkDto, SHLinkDto } from '@/domain/dtos/shlink'; import { ISHLinkRepository } from '@/infrastructure/repositories/interfaces/shlink-repository'; +import { LogHandler } from '@/lib/logger'; import { mapDtoToModel, mapModelToDto, @@ -15,6 +16,8 @@ import { getSHLinkUseCase } from '@/usecases/shlinks/get-shlink'; const repo = container.get(SHLinkRepositoryToken); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/share-links: @@ -39,12 +42,13 @@ const repo = container.get(SHLinkRepositoryToken); export async function POST(request: Request) { try { const dto: CreateSHLinkDto = await request.json(); + logger.info(`Creating a share link API with parameters, ${JSON.stringify({name:dto.name, userId:dto.userId})}`); await validateUser(request, dto.userId); const model = mapDtoToModel(dto as SHLinkDto); const newShlink = await addShlinkUseCase({ repo }, { shlink: model }); return NextResponse.json(mapModelToDto(newShlink), { status: 200 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } @@ -68,12 +72,14 @@ export async function GET(request: Request) { try { const { id } = await getUserProfile(request); + logger.info(`Getting all share links by user with user id: ${id}`); + const newShlink = await getSHLinkUseCase({ repo }, { user_id: id }); return NextResponse.json( newShlink.map((shlink) => mapModelToMiniDto(shlink)), { status: 200 }, ); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/users/[id]/ips/route.test.ts b/src/app/api/v1/users/[id]/ips/route.test.ts index 60b1387..3d81433 100644 --- a/src/app/api/v1/users/[id]/ips/route.test.ts +++ b/src/app/api/v1/users/[id]/ips/route.test.ts @@ -24,6 +24,8 @@ jest.mock('@/usecases/users/get-user', () => ({ getUserUseCase: jest.fn(), })); +const mockRoute = '/api/v1/users/ips' + describe('GET handler', () => { const mockRequest = (id: string) => new NextRequest(`http://localhost/api/users/${id}/ips`, { diff --git a/src/app/api/v1/users/[id]/ips/route.ts b/src/app/api/v1/users/[id]/ips/route.ts index fc14e29..50943e1 100644 --- a/src/app/api/v1/users/[id]/ips/route.ts +++ b/src/app/api/v1/users/[id]/ips/route.ts @@ -10,6 +10,7 @@ import { } from '@/container'; import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { IUserRepository } from '@/infrastructure/repositories/interfaces/user-repository'; +import { LogHandler } from '@/lib/logger'; import { getPatientDataUseCase } from '@/usecases/patient/get-patient-data'; import { getUserUseCase } from '@/usecases/users/get-user'; @@ -18,6 +19,9 @@ const serverConfigRepo = container.get( ServerConfigRepositoryToken, ); +const logger = new LogHandler(__dirname) + + /** * @swagger * /api/v1/users/{id}/ips: @@ -41,6 +45,7 @@ export async function GET( request: Request, { params }: { params: { id: string } }, ) { + logger.log(`Retrieving user's patient summary data with user id: ${params.id}`) try { await validateUser(request, params.id); const user = await getUserUseCase( @@ -49,6 +54,7 @@ export async function GET( ); if (!user) return NextResponse.json({ message: NOT_FOUND }, { status: 404 }); + logger.log(`Retrieving patient summary data from FHIR with user: ${JSON.stringify(user)}`) const result = await getPatientDataUseCase( { repo: serverConfigRepo }, { user }, @@ -56,6 +62,6 @@ export async function GET( return NextResponse.json(result, { status: 200 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/users/[id]/route.test.ts b/src/app/api/v1/users/[id]/route.test.ts index 0f579b2..709a1f8 100644 --- a/src/app/api/v1/users/[id]/route.test.ts +++ b/src/app/api/v1/users/[id]/route.test.ts @@ -40,9 +40,11 @@ describe('GET /api/users/[id]', () => { patientId: 'user-patient-id', }; + const mockRoute = '/api/v1/users/{id}' + it('should return user DTO and status 200 when user is found and validation passes', async () => { // Mock implementations - (validateUser as jest.Mock).mockResolvedValue(undefined); // Assuming validateUser returns undefined on success + (validateUser as jest.Mock).mockResolvedValue(undefined); (getUserUseCase as jest.Mock).mockResolvedValue(mockUser); (mapModelToDto as jest.Mock).mockReturnValue(mockDto); @@ -60,7 +62,7 @@ describe('GET /api/users/[id]', () => { }); it('should return NOT_FOUND message and status 404 when user is not found', async () => { - (validateUser as jest.Mock).mockResolvedValue(undefined); // Assuming validateUser returns undefined on success + (validateUser as jest.Mock).mockResolvedValue(undefined); (getUserUseCase as jest.Mock).mockResolvedValue(null); const mockRequest = new NextRequest( @@ -93,7 +95,7 @@ describe('GET /api/users/[id]', () => { const response = await GET(mockRequest, { params: { id: 'user-id' } }); expect(validateUser).toHaveBeenCalledWith(mockRequest, 'user-id'); - expect(handleApiValidationError).toHaveBeenCalledWith(error); + expect(handleApiValidationError).toHaveBeenCalledWith(error, expect.anything()); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(400); diff --git a/src/app/api/v1/users/[id]/route.ts b/src/app/api/v1/users/[id]/route.ts index 1932849..8a8fc24 100644 --- a/src/app/api/v1/users/[id]/route.ts +++ b/src/app/api/v1/users/[id]/route.ts @@ -5,11 +5,14 @@ import { validateUser } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, UserRepositoryToken } from '@/container'; import { IUserRepository } from '@/infrastructure/repositories/interfaces/user-repository'; +import { LogHandler } from '@/lib/logger'; import { mapModelToDto } from '@/mappers/user-mapper'; import { getUserUseCase } from '@/usecases/users/get-user'; const repo = container.get(UserRepositoryToken); +const logger = new LogHandler(__dirname) + /** * @swagger * /api/v1/users/{id}: @@ -34,6 +37,7 @@ export async function GET( request: Request, { params }: { params: { id: string } }, ) { + logger.log(`Retrieving a user with user id: ${params.id}`) try { const { id } = params; validateUser(request, id); @@ -44,6 +48,6 @@ export async function GET( return NextResponse.json({ message: NOT_FOUND }, { status: 404 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/api/v1/users/route.test.ts b/src/app/api/v1/users/route.test.ts index a68786e..79cd8c8 100644 --- a/src/app/api/v1/users/route.test.ts +++ b/src/app/api/v1/users/route.test.ts @@ -5,10 +5,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { POST } from '@/app/api/v1/users/route'; +import { getUserProfile } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { mapDtoToModel, mapModelToDto } from '@/mappers/user-mapper'; import { HapiFhirServiceFactory } from '@/services/hapi-fhir-factory'; -import { FhirPatient, IHapiFhirService } from '@/services/hapi-fhir.interface'; +import { IHapiFhirService } from '@/services/hapi-fhir.interface'; import { searchPatientUseCase } from '@/usecases/patient/search-patient'; import { addUserUseCase } from '@/usecases/users/add-user'; @@ -16,6 +17,10 @@ jest.mock('@/usecases/users/add-user', () => ({ addUserUseCase: jest.fn(), })); +jest.mock('@/app/utils/authentication', () => ({ + getUserProfile: jest.fn(), +})); + jest.mock('@/services/hapi-fhir-factory'); jest.mock('@/usecases/patient/search-patient', () => ({ @@ -54,7 +59,10 @@ describe('POST /api/users', () => { method: 'POST', body: JSON.stringify(body), }); + + const mockRoute = '/api/v1/users'; let mockService: jest.Mocked; + (getUserProfile as jest.Mock).mockResolvedValue(true); HapiFhirServiceFactory.getService = jest.fn().mockReturnValue(mockService); @@ -88,7 +96,10 @@ describe('POST /api/users', () => { const request = mockRequest(mockCreateUserDto); const response = await POST(request); - expect(handleApiValidationError).toHaveBeenCalledWith(error); + expect(handleApiValidationError).toHaveBeenCalledWith( + error, + expect.anything(), + ); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(400); @@ -106,7 +117,10 @@ describe('POST /api/users', () => { const request = mockRequest(mockCreateUserDto); const response = await POST(request); - expect(handleApiValidationError).toHaveBeenCalledWith(error); + expect(handleApiValidationError).toHaveBeenCalledWith( + error, + expect.anything(), + ); expect(response).toBeInstanceOf(NextResponse); expect(response.status).toBe(500); diff --git a/src/app/api/v1/users/route.ts b/src/app/api/v1/users/route.ts index b7d5e7b..9861be7 100644 --- a/src/app/api/v1/users/route.ts +++ b/src/app/api/v1/users/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; +import { getUserProfile } from '@/app/utils/authentication'; import { handleApiValidationError } from '@/app/utils/error-handler'; import { container, @@ -9,6 +10,7 @@ import { import { CreateUserDto, UserDto } from '@/domain/dtos/user'; import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { IUserRepository } from '@/infrastructure/repositories/interfaces/user-repository'; +import { LogHandler } from '@/lib/logger'; import { mapDtoToModel, mapModelToDto } from '@/mappers/user-mapper'; import { searchPatientUseCase } from '@/usecases/patient/search-patient'; import { addUserUseCase } from '@/usecases/users/add-user'; @@ -18,6 +20,8 @@ const serverConfigRepo = container.get( ServerConfigRepositoryToken, ); +const logger = new LogHandler(__dirname); + /** * @swagger * /api/v1/users: @@ -41,16 +45,18 @@ const serverConfigRepo = container.get( */ export async function POST(request: Request) { let dto: CreateUserDto = await request.json(); + logger.log(`Creating a user with, ${JSON.stringify(dto)}`); try { + const { email } = await getUserProfile(request); const patientId = await searchPatientUseCase( { repo: serverConfigRepo }, - { patientId: dto.patientId }, + { patientId: dto.patientId, email }, ); dto.patientId = patientId; const model = mapDtoToModel(dto as UserDto); const newUser = await addUserUseCase({ repo }, { user: model }); return NextResponse.json(mapModelToDto(newUser), { status: 201 }); } catch (error) { - return handleApiValidationError(error); + return handleApiValidationError(error, logger); } } diff --git a/src/app/utils/authentication.ts b/src/app/utils/authentication.ts index a913e1d..eb28c4b 100644 --- a/src/app/utils/authentication.ts +++ b/src/app/utils/authentication.ts @@ -1,4 +1,5 @@ import { NextRequest } from 'next/server'; +import { Account, Session } from 'next-auth'; import { getToken } from 'next-auth/jwt'; import { UNAUTHORIZED_REQUEST } from '../constants/http-constants'; @@ -7,6 +8,7 @@ export interface UserProfile { name: string; id: string; email: string; + roles?: string[]; } interface TokenPayload { @@ -15,16 +17,43 @@ interface TokenPayload { id: string; email: string; }; + roles?: string[]; } +export const getRoles = (token: Session) => { + const account = token.account as Account; + + if (account?.access_token) { + const base64Payload = account.access_token.split('.')[1]; + const payload = Buffer.from(base64Payload, 'base64').toString('utf-8'); + const innerToken = JSON.parse(payload); + return innerToken.resource_access?.nextjs?.roles || []; + } + return [] as string[]; +}; + export const getUserProfile = async (req: Request): Promise => { - const { - user: { name, id, email }, - } = (await getToken({ + const token = (await getToken({ req: req as NextRequest, })) as unknown as TokenPayload; - return { name, id, email }; + const roles = getRoles(token); + + const { + user: { name, id, email }, + } = token; + return { name, id, email, roles }; +}; + +export const validateUserRoles = async (req: Request, role: string) => { + const user = await getUserProfile(req); + if (!user.roles?.find((x) => x === role)) { + throw new AuthenticationError( + `User not member of the following role or group: "${role}".`, + ); + } + + return true; }; export const validateUser = async (req: Request, userId: string) => { diff --git a/src/app/utils/error-handler.ts b/src/app/utils/error-handler.ts index aa26290..65ff2a7 100644 --- a/src/app/utils/error-handler.ts +++ b/src/app/utils/error-handler.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { ModelValidationError } from '@/domain/models/base-model'; +import { LogHandler } from '@/lib/logger'; import { ExternalDataFetchError } from '@/services/hapi-fhir.service'; import { SHLinkValidationError } from '@/usecases/shlinks/validate-shlink'; @@ -11,8 +12,9 @@ import { SERVER_ERROR, } from '../constants/http-constants'; -export function handleApiValidationError(error: unknown) { - console.error('API route error:', error); + +export function handleApiValidationError(error: Error, logger:LogHandler) { + logger.error(error); if (error instanceof ModelValidationError) { return NextResponse.json( diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000..419c37f --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,20 @@ +import 'next-auth'; + +declare module 'next-auth' { + interface Session { + user?: { + id?: string; + name?: string; + email?: string; + }; + token?: { + name?: string; + email?: string; + sub?: string; + }; + expires?: string; + account?: { + access_token?: string; + }; + } +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..1102384 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,130 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { format, transports, createLogger, Logger } from 'winston'; +import DailyRotateFile from "winston-daily-rotate-file"; + +const getConfig = (): { transporters: any[], format: any[] } => { + const defaultFormat = ['timestamp', 'json']; + try { + const configPath = path.resolve('./logger.config.json'); + + if (!fs.existsSync(configPath)) { + throw new Error("Configuration file missing"); + } + + const rawData = fs.readFileSync(configPath, 'utf-8'); + let logConfig = JSON.parse(rawData); + logConfig.transporters = logConfig.transporters || []; + logConfig.format = logConfig.format || defaultFormat; + + return logConfig; + } catch (error) { + console.warn("Using default configuration due to error:", error.message); + + return { + transporters: [], + format: defaultFormat + }; + } +}; + +const loggerConfig = getConfig(); + +const { combine, timestamp, printf, colorize, json, errors, prettyPrint } = format; + +function getTransporters(){ + return [ + new transports.Console(), + ...getTransportersFromConfig(loggerConfig) + ] +} + +function getTransportersFromConfig(config:any){ + return config.transporters.map(x => { + if(x.transporterType === 'file'){ + return new DailyRotateFile(x.options) + } + }).filter(x => x) +} + +function getFormat(){ + return combine( + errors({ stack: true }), + prettyPrint(), + ...getFormatFromConfig(loggerConfig) + ) +} + +const formatFunctions = { + timestamp: timestamp, + colorize: colorize, + json: json, +}; + +function getFormatFromConfig(config:any){ + return config.format.map(x => { + if(Array.isArray(x) && typeof(x) !== "string"){ + return printf((formatObject) => { + + let logParts: string[] = []; + + x.forEach((formatPart: string) => { + if (formatPart === "level") logParts.push(`[${formatObject[formatPart]}]`); + else if(formatPart in formatObject) logParts.push(formatObject[formatPart]) + }); + return logParts.join(' '); + }); + } + else if (typeof x === 'string' && x in formatFunctions) { + return formatFunctions[x](); + } + }).filter(x => x) +} + +export function getLogger(module:string){ + + const logger: Logger = createLogger({ + defaultMeta:{ + service:'share_link_api_service', + module:module + }, + format: getFormat(), + transports: getTransporters() + }); + + return logger; +} + + +export class LogHandler { + private logger: Logger; + + constructor(module: string) { + this.logger = getLogger(module); + } + + log( message: string, level: 'info' | 'debug' | 'warn' | 'error' = 'info') { + this.logger.log({ + level, + message + }); + } + + info(message: string) { + this.log(message, 'info'); + } + + debug(message: string) { + this.log(message, 'debug'); + } + + warn(message: string) { + this.log(message, 'warn'); + } + + error(error: Error) { + this.logger.error(error); + } + } + \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index b66de6f..8865a9d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,10 +1,27 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; -export async function middleware(req: Request) { +const FRONTEND_PATHNAMES = ['/', '/patient-summary']; +const REDIRECTION_PATHNAME = '/'; + +const isApiPathname = (pathname: string) => pathname.startsWith('/api/v1/'); +const isFrontendPathname = (pathname: string) => + FRONTEND_PATHNAMES.includes(pathname); + +export async function middleware(req: NextRequest) { + const token = await getToken({ req }); + const { nextUrl, url } = req; + const { pathname } = nextUrl; + try { - const token = await getToken({ req: req as any }); - if (!token) throw new Error(); + if (!token) { + if (isApiPathname(pathname)) throw new Error(); + + if (pathname !== REDIRECTION_PATHNAME && isFrontendPathname(pathname)) { + const redirectionUrl = new URL(REDIRECTION_PATHNAME, url); + return NextResponse.redirect(redirectionUrl); + } + } return NextResponse.next(); } catch (error) { @@ -16,5 +33,5 @@ export async function middleware(req: Request) { } export const config = { - matcher: '/api/v1/:path*', + matcher: '/:path*', }; diff --git a/src/services/hapi-fhir.interface.ts b/src/services/hapi-fhir.interface.ts index adb115b..6d274e7 100644 --- a/src/services/hapi-fhir.interface.ts +++ b/src/services/hapi-fhir.interface.ts @@ -9,7 +9,7 @@ export interface IHapiFhirService { params: any, options?: HapiFhirRequestOptions, ): Promise; - searchPatient(patientId: string): Promise>; + searchPatient(patientId: string): Promise; } interface FhirMeta { diff --git a/src/services/hapi-fhir.service.ts b/src/services/hapi-fhir.service.ts index a5d8054..6a717e7 100644 --- a/src/services/hapi-fhir.service.ts +++ b/src/services/hapi-fhir.service.ts @@ -3,7 +3,6 @@ import BaseService from './base-service.service'; import { IHapiFhirService, HapiFhirRequestOptions, - FhirSearchResult, } from './hapi-fhir.interface'; export class ExternalDataFetchError extends Error { @@ -23,10 +22,8 @@ export class HapiFhirService constructor(baseUrl: string) { super(baseUrl, 'fhir/Patient'); } - async searchPatient(patientId: string): Promise> { - return this.get('', { identifier: patientId }) as Promise< - FhirSearchResult - >; + async searchPatient(patientId: string): Promise { + return this.get('', { identifier: patientId }); } async getAccessToken( diff --git a/src/usecases/patient/get-patient-data.ts b/src/usecases/patient/get-patient-data.ts index 8eaf3e5..7a9e840 100644 --- a/src/usecases/patient/get-patient-data.ts +++ b/src/usecases/patient/get-patient-data.ts @@ -8,17 +8,24 @@ export const getPatientDataUseCase = async ( context: { repo: IServerConfigRepository }, data: { user: UserModel }, ): Promise => { - const serverConfig = (await context.repo.findMany()).find((x) => x); + const serverConfigs = await context.repo.findMany(); - if (!serverConfig) { + if (!serverConfigs.length) { throw new ExternalDataFetchError('Missing Config error.'); } try { - const service = HapiFhirServiceFactory.getService(serverConfig); - const result = await service.getPatientData>( - data.user.getPatientId(), - {}, - ); + let result: FhirBundle; + for (const serverConfig of serverConfigs) { + try { + const service = HapiFhirServiceFactory.getService(serverConfig); + result = await service.getPatientData>( + data.user.getPatientId(), + {}, + ); + if (result) break; + } catch {} + } + if (!result) { throw new ExternalDataFetchError('Unfullfilled request'); } diff --git a/src/usecases/patient/search-patient-data.test.ts b/src/usecases/patient/search-patient-data.test.ts index fa650f0..9cd8c9a 100644 --- a/src/usecases/patient/search-patient-data.test.ts +++ b/src/usecases/patient/search-patient-data.test.ts @@ -1,3 +1,4 @@ +import { getUserProfile } from '@/app/utils/authentication'; import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { HapiFhirServiceFactory } from '@/services/hapi-fhir-factory'; import { @@ -10,12 +11,18 @@ import { ExternalDataFetchError } from '@/services/hapi-fhir.service'; import { searchPatientUseCase } from './search-patient'; // Mock the HapiFhirServiceFactory and IServerConfigRepository +jest.mock('@/app/utils/authentication', () => ({ + getUserProfile: jest.fn(), +})); jest.mock('@/services/hapi-fhir-factory'); jest.mock('@/infrastructure/repositories/interfaces/server-config-repository'); describe('searchPatientUseCase', () => { let mockRepo: jest.Mocked; let mockService: jest.Mocked; + const email = 'test@email.com'; + + (getUserProfile as jest.Mock).mockResolvedValue(true); beforeEach(() => { mockRepo = { @@ -38,12 +45,19 @@ describe('searchPatientUseCase', () => { // Mock patient search result mockService.searchPatient.mockResolvedValue({ - entry: [{ resource: { id: expectedId } }], - } as FhirSearchResult); + entry: [ + { + resource: { + id: expectedId, + telecom: [{ system: 'email', value: email }], + }, + }, + ], + }); const result = await searchPatientUseCase( { repo: mockRepo }, - { patientId }, + { patientId, email }, ); expect(result).toBe(expectedId); @@ -54,7 +68,7 @@ describe('searchPatientUseCase', () => { mockRepo.findMany.mockResolvedValue([]); await expect( - searchPatientUseCase({ repo: mockRepo }, { patientId: '12345' }), + searchPatientUseCase({ repo: mockRepo }, { patientId: '12345', email }), ).rejects.toThrow(new ExternalDataFetchError('Missing Config error.')); }); @@ -70,7 +84,7 @@ describe('searchPatientUseCase', () => { } as FhirSearchResult); await expect( - searchPatientUseCase({ repo: mockRepo }, { patientId }), + searchPatientUseCase({ repo: mockRepo }, { patientId, email }), ).rejects.toThrow( new ExternalDataFetchError('Patient Data not found.', 404), ); diff --git a/src/usecases/patient/search-patient.ts b/src/usecases/patient/search-patient.ts index a10b0d4..e7db607 100644 --- a/src/usecases/patient/search-patient.ts +++ b/src/usecases/patient/search-patient.ts @@ -1,3 +1,5 @@ +import { Patient } from 'fhir/r4'; + import { IServerConfigRepository } from '@/infrastructure/repositories/interfaces/server-config-repository'; import { HapiFhirServiceFactory } from '@/services/hapi-fhir-factory'; import { @@ -9,21 +11,40 @@ import { ExternalDataFetchError } from '@/services/hapi-fhir.service'; export const searchPatientUseCase = async ( context: { repo: IServerConfigRepository }, - data: { patientId: string }, + data: { patientId: string; email: string }, ): Promise => { - const serviceConfig = (await context.repo.findMany({})).find((x) => x); - if (!serviceConfig) { + const serviceConfigs = await context.repo.findMany({}); + if (!serviceConfigs.length) { throw new ExternalDataFetchError('Missing Config error.'); } - const service: IHapiFhirService = - HapiFhirServiceFactory.getService(serviceConfig); - const result = await service.searchPatient>( - data.patientId, - ); + let result: FhirSearchResult; + for (const serviceConfig of serviceConfigs) { + try { + const service: IHapiFhirService = + HapiFhirServiceFactory.getService(serviceConfig); + result = await service.searchPatient>( + data.patientId, + ); + if (result) break; + } catch {} + } - if (!result || !result.entry?.length) { + if ( + !result || + !result.entry?.length || + !findEmailAddress( + result.entry[0].resource as unknown as Patient, + data.email, + ) + ) { throw new ExternalDataFetchError('Patient Data not found.', 404); } return result.entry[0].resource.id; }; + +export const findEmailAddress = (patient: Patient, email: string) => { + return patient.telecom?.find( + (x) => x.system === 'email' && x.value === email, + ); +};