diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5526f07..7a20c48 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -55,4 +55,8 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_BUCKET_NAME: ${{ secrets.AWS_BUCKET_NAME }} AWS_REGION: ${{ secrets.AWS_REGION }} + TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + TWILIO_PHONE_NUMBER: ${{ secrets.TWILIO_PHONE_NUMBER }} + TWILIO_VIRTUAL_NUMBER: ${{ secrets.TWILIO_VIRTUAL_NUMBER }} run: npm test diff --git a/Dockerfile b/Dockerfile index 727645b..b353286 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,22 @@ -From node:22-alpine +FROM node:22-alpine WORKDIR /usr/src/app +# Copy package files first COPY package*.json ./ -RUN npm install -COPY . . +# Clear any old dependencies and reinstall +RUN rm -rf node_modules && npm install +# Copy rest of the app source +COPY . . +# Copy env file (only if needed inside container) COPY .env .env + +# Generate Prisma client RUN npx prisma generate + EXPOSE 3000 -CMD ["npm", "run", "dev"] \ No newline at end of file + +CMD ["npm", "run", "dev"] diff --git a/__tests__/messaging.test.ts b/__tests__/messaging.test.ts new file mode 100644 index 0000000..b8196d2 --- /dev/null +++ b/__tests__/messaging.test.ts @@ -0,0 +1,15 @@ +import request from 'supertest' +import app from '../src/app' +import prisma from '../src/services/prisma' + +test('POST /v1/location/:id/messaging/send-welcome sends a welcome message', async () => { + // First, create a location to use + const newLocation = await prisma.location.create({ data: { name: 'Messaging Test Location' } }) + + const res = await request(app) + .post(`/v1/location/${newLocation.id}/messaging/send-welcome`) + .send({ ticketNumber: '12345' }) + + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('message', 'Welcome message sent successfully') +}) diff --git a/package-lock.json b/package-lock.json index 2de6d5e..e395445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "multer": "^2.0.2", "multer-s3": "^3.0.1", "pg": "^8.16.3", - "pino": "^8.19.0" + "pino": "^8.19.0", + "twilio": "^5.10.3" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -33,6 +34,7 @@ "@types/multer": "^2.0.0", "@types/node": "^20.11.30", "@types/supertest": "^6.0.3", + "@types/twilio": "^3.19.2", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "dotenv-cli": "^10.0.0", @@ -4513,6 +4515,16 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/twilio": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@types/twilio/-/twilio-3.19.2.tgz", + "integrity": "sha512-yMEBc7xS1G4Dd4w5xvfDIJkSVVZmiGP/Lrpr4QqUus9rENPjt9BUag5NL198cO2EoJNI8Tqy8qMcKO9jd+9Ssg==", + "dev": true, + "license": "MIT", + "dependencies": { + "twilio": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -5171,6 +5183,41 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5287,7 +5334,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -5298,6 +5344,17 @@ "node": ">=8.0.0" } }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -5910,7 +5967,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6138,6 +6194,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6198,7 +6260,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6482,7 +6543,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7203,6 +7263,26 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7237,7 +7317,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7534,7 +7613,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7599,6 +7677,42 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -10011,6 +10125,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -10271,6 +10391,12 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "license": "BSD-3-Clause" + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -11024,6 +11150,24 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/twilio": { + "version": "5.10.3", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.10.3.tgz", + "integrity": "sha512-msve3uADprpG+LRlthOxBUJWZDczGe+mdzotG7Wluaf8nn8fSIK0n2fX3INR26Xedeea/azmAdLK0c2rJhIHpQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "qs": "^6.9.4", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11355,6 +11499,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 2fff5dc..5f0ec77 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "multer": "^2.0.2", "multer-s3": "^3.0.1", "pg": "^8.16.3", - "pino": "^8.19.0" + "pino": "^8.19.0", + "twilio": "^5.10.3" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -51,6 +52,7 @@ "@types/multer": "^2.0.0", "@types/node": "^20.11.30", "@types/supertest": "^6.0.3", + "@types/twilio": "^3.19.2", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "dotenv-cli": "^10.0.0", diff --git a/src/resources/locations/routes.ts b/src/resources/locations/routes.ts index 3ed40f5..b05007d 100644 --- a/src/resources/locations/routes.ts +++ b/src/resources/locations/routes.ts @@ -3,16 +3,23 @@ import locationController from './controller' import entranceRouter from '../entrances/routes' import employeeRouter from '../employee/routes' import authRouter from '../auth/routes' +import messageRouter from '../messaging/routes' + const router = Router() -// define routes -router.route('/create').post(locationController.makeLocation) -router.delete('/:id', locationController.removeLocation) +// create and general routes +router.post('/create', locationController.makeLocation) router.get('/', locationController.getLocations) -router.get('/:id', locationController.getSingleLocation) -router.put('/:id', locationController.updateLocation) + +// 🔹 mount nested routers FIRST +router.use('/:locationId/messaging', messageRouter) router.use('/:locationId/auth', authRouter) router.use('/:locationId/employee', employeeRouter) router.use('/:locationId/entrance', entranceRouter) +// 🔹 parameterized single routes AFTER nested ones +router.get('/:id', locationController.getSingleLocation) +router.put('/:id', locationController.updateLocation) +router.delete('/:id', locationController.removeLocation) + export default router diff --git a/src/resources/messaging/controller.ts b/src/resources/messaging/controller.ts new file mode 100644 index 0000000..6980f10 --- /dev/null +++ b/src/resources/messaging/controller.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from 'express' +import sendSms from '../../services/sendSms' +import { getLocationById } from '../../services/locationService' +const virtualNumber = process.env.TWILIO_VIRTUAL_NUMBER || '' +const sendWelcomeMessage = async (req: Request, res: Response, next: NextFunction) => { + try { + const { ticketNumber } = req.body + const { locationId } = req.params + console.log('Location ID:', locationId) + const location = await getLocationById(parseInt(locationId)) + if (!location) { + return res.status(404).json({ error: 'Location not found' }) + } + // if (!phoneNumber) { + // return res.status(400).json({ error: 'Phone number is required' }) + // } + if (!ticketNumber) { + return res.status(400).json({ error: 'Ticket number is required' }) + } + + const message = `Welcome to ${location.name}! Your your ticket number is ${ticketNumber}.` + //send sms to virtual phone number in development, get real phone number from req.params in production + const result = sendSms(virtualNumber, message) + return res.status(200).json({ message: 'Welcome message sent successfully', result }) + } catch (error) { + console.error('Error sending welcome message:', error) + next(error) + } +} + +export { sendWelcomeMessage } diff --git a/src/resources/messaging/routes.ts b/src/resources/messaging/routes.ts new file mode 100644 index 0000000..9481eee --- /dev/null +++ b/src/resources/messaging/routes.ts @@ -0,0 +1,7 @@ +import { Router } from 'express' +import { sendWelcomeMessage } from './controller' +const router = Router({ mergeParams: true }) + +router.post('/send-welcome', sendWelcomeMessage) + +export default router diff --git a/src/services/sendSms.ts b/src/services/sendSms.ts new file mode 100644 index 0000000..3816bf6 --- /dev/null +++ b/src/services/sendSms.ts @@ -0,0 +1,23 @@ +import 'dotenv/config' +import twilio from 'twilio' + +const accountSid = process.env.TWILIO_ACCOUNT_SID +const authToken = process.env.TWILIO_AUTH_TOKEN +const fromPhoneNumber = process.env.TWILIO_PHONE_NUMBER + +const client = twilio(accountSid, authToken) + +export const sendSms = async (to: string, body: string): Promise => { + try { + await client.messages.create({ + body, + from: fromPhoneNumber!, + to, + }) + } catch (error) { + console.error('Error sending SMS:', error) + throw error + } +} + +export default sendSms