diff --git a/.circleci/config.yml b/.circleci/config.yml index abca6089..6578f255 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -171,6 +171,56 @@ jobs: webhook: '${DISCORD_STATUS_WEBHOOK}' success_message: '**${CIRCLE_USERNAME}** Cboard API Postman tests passed on QA!!.' + qa-image: + docker: + - image: cimg/node:18.18.1 + auth: + username: cboardci + password: $DOCKERHUB_PASSWORD + working_directory: ~/repo + steps: + - checkout + - restore_cache: + keys: + - yarn-packages-v1-{{ .Branch }}-{{ checksum “yarn.lock” }} + - yarn-packages-v1-{{ .Branch }}- + - yarn-packages-v1- + - run: yarn install --cache-folder ~/.cache/yarn + - save_cache: + paths: + - ~/.cache/yarn + key: yarn-packages-v1-{{ .Branch }}-{{ checksum “yarn.lock” }} + - setup_remote_docker: + docker_layer_caching: false + - run: | + openssl aes-256-cbc -d -md sha256 \ + -in google-auth.json.cipher \ + -out google-auth.json \ + -k $KEY + - run: | + openssl aes-256-cbc -d -md sha256 \ + -pbkdf2 \ + -in google-play-auth.json.cipher \ + -out google-play-auth.json \ + -k $KEY + - run: | + openssl aes-256-cbc -d -md sha256 \ + -pbkdf2 \ + -in Apple-Sign-In-AuthKey.p8.cipher \ + -out Apple-Sign-In-AuthKey.p8 \ + -k $KEY + - run: | + openssl aes-256-cbc -d -md sha256 \ + -pbkdf2 \ + -in App-Store-Connect-API-Key.p8.cipher \ + -out App-Store-Connect-API-Key.p8 \ + -k $KEY + - run: | + TAG=qa-$CIRCLE_BUILD_NUM + docker build -t cboard/cboard-api:qa -t cboard/cboard-api:$TAG . + docker login -u $DOCKER_USER -p $DOCKER_PASS + docker push cboard/cboard-api:qa + docker push cboard/cboard-api:$TAG workflows: version: 2 build_test_image: @@ -196,3 +246,10 @@ workflows: filters: branches: only: master + - qa-image: + context: google-auth + requires: + - build + filters: + branches: + only: feature/cboard-access diff --git a/api/controllers/access.js b/api/controllers/access.js new file mode 100644 index 00000000..a30ff285 --- /dev/null +++ b/api/controllers/access.js @@ -0,0 +1,493 @@ +'use strict'; + +const AccessClient = require('../models/AccessClient'); +const AccessGate = require('../models/AccessGate'); +const Board = require('../models/Board'); + +module.exports = { + getClients: getClients, + getAccessBoards: getAccessBoards, + createAccessClient: createAccessClient, + listAccessClients: listAccessClients, + updateAccessClient: updateAccessClient, + updateAccessGate: updateAccessGate, + getAccessClientStats: getAccessClientStats +}; + +/** + * Recursively discovers all boards reachable from a root board via tile.loadBoard references. + * The visited Set is shared across all recursive calls (passed by reference), so any board + * already discovered is skipped immediately — this prevents infinite loops from tiles that + * navigate back to a parent board (e.g. a "home" tile). + * @param {string|ObjectId} rootBoardId - Starting board ID + * @param {Set} visited - Shared set of already-visited IDs (prevents cycles) + */ +async function discoverLinkedBoards(rootBoardId, visited) { + const id = rootBoardId.toString(); + if (visited.has(id)) return; // Already discovered — skip to prevent cycles + visited.add(id); + + const board = await Board.findById(id); + if (!board?.tiles) return; + + for (const tile of board.tiles) { + if (tile.loadBoard) { + await discoverLinkedBoards(tile.loadBoard, visited); + } + } +} + +/** + * Returns all board IDs reachable from rootBoardId, including rootBoardId itself. + * @param {string|ObjectId} rootBoardId + * @returns {Promise} + */ +async function getAllLinkedBoardIds(rootBoardId) { + const visited = new Set(); + await discoverLinkedBoards(rootBoardId, visited); + return [...visited]; +} + +/** + * GET /access/clients/all + * Lists all active clients with valid subscriptions and their access gates. + * Returns each client with its access gates and root board basic info. + * Sorted by client name. + */ +async function getClients(req, res) { + try { + const now = new Date(); + const clients = await AccessClient.find({ + isActive: true, + subscriptionStart: { $lte: now }, + subscriptionEnd: { $gte: now } + }).select('slug contact brandColor'); + + const accessGates = await AccessGate.find({ + accessClientId: { $in: clients.map(c => c._id) } + }) + .populate('rootBoard', 'name caption tiles') + .select('code rootBoardId accessClientId'); + + const result = clients + .map(client => ({ + slug: client.slug, + clientName: client.contact.name, + brandColor: client.brandColor, + accessGates: accessGates + .filter(ag => ag.accessClientId.toString() === client._id.toString()) + .map(ag => ({ + code: ag.code, + rootBoard: ag.rootBoard + ? { + id: ag.rootBoard._id, + name: ag.rootBoard.name, + caption: ag.rootBoard.caption, + tilesCount: ag.rootBoard.tiles?.length || 0 + } + : null + })) + })) + .sort((a, b) => a.clientName.localeCompare(b.clientName)); + + return res.status(200).json({ + total: result.length, + data: result + }); + } catch (err) { + return res.status(500).json({ + message: 'Error listing clients', + error: err.message + }); + } +} + +/** + * GET /access/:clientSlug/:gateCode + * Gets ALL boards for a slug + access code pair in a single request. + * This enables instant frontend navigation without additional requests. + * Validates slug and code exist, belong together, and client subscription is active. + * Returns client info (slug, name, color), all boards array, and rootBoardId. + * Increments viewsCount and updates lastAccessAt on the AccessGate. + */ +async function getAccessBoards(req, res) { + const code = req.swagger.params.gateCode.value.toUpperCase(); + const slug = req.swagger.params.clientSlug.value; + + try { + const now = new Date(); + const accessGate = await AccessGate.findOne({ code }).populate( + 'accessClient' + ); + + if (!accessGate) { + return res.status(404).json({ + message: 'Invalid access code' + }); + } + + const client = accessGate.accessClient; + if ( + !client || + client.slug !== slug || + !client.isActive || + client.subscriptionStart > now || + client.subscriptionEnd < now + ) { + return res.status(404).json({ + message: 'Invalid or expired access code' + }); + } + + // Fetch all linked boards (exclude PII fields for public endpoint) + const boards = await Board.find({ + _id: { $in: accessGate.linkedBoardIds } + }).select('-email -author'); + + if (!boards || boards.length === 0) { + return res.status(404).json({ + message: 'No boards available for this code' + }); + } + + // Verify the rootBoard exists among the linked boards + const rootBoard = boards.find( + b => b._id.toString() === accessGate.rootBoardId.toString() + ); + if (!rootBoard) { + return res.status(404).json({ + message: 'Root board not found' + }); + } + + // Fire-and-forget analytics: never block the response or surface tracking errors to the caller. + // $inc is atomic (safe under concurrency); $currentDate uses MongoDB server time for consistency across app nodes. + AccessGate.updateOne( + { _id: accessGate._id }, + { + $inc: { viewsCount: 1 }, + $currentDate: { lastAccessAt: true } + } + ).catch(err => + console.error('[access] viewsCount update failed', { + gateId: accessGate._id, + error: err.message + }) + ); + + return res.status(200).json({ + client: { + slug: client.slug, + name: client.contact.name, + color: client.brandColor + }, + boards: boards.map(b => b.toJSON()), + rootBoardId: accessGate.rootBoardId.toString() + }); + } catch (err) { + return res.status(500).json({ + message: 'Error getting boards', + error: err.message + }); + } +} + +// ============================================ +// ADMIN ENDPOINTS +// ============================================ + +/** + * POST /admin/access/clients + * Creates a new Access client and its first Access gate. + * Requires admin authentication (enforced by Swagger middleware). + * Creates AccessClient, creates AccessGate, and updates boards with accessGate. + * Validates rootBoardId exists. + */ +async function createAccessClient(req, res) { + const { + slug, + clientName, + clientEmail, + clientPhone, + brandColor, + rootBoardId, + subscriptionStart, + subscriptionEnd, + accessGateCode + } = req.body; + + try { + // Verify that the root board exists + const rootBoard = await Board.findById(rootBoardId); + if (!rootBoard) { + return res.status(404).json({ message: 'Root board not found' }); + } + + // Check for duplicates before persisting anything to avoid orphaned documents + const [existingSlug, existingCode] = await Promise.all([ + AccessClient.findOne({ slug }), + AccessGate.findOne({ code: accessGateCode.toUpperCase() }) + ]); + if (existingSlug) { + return res.status(409).json({ message: 'Slug already exists' }); + } + if (existingCode) { + return res.status(409).json({ message: 'Code already exists' }); + } + + // Create the client + const client = new AccessClient({ + slug, + contact: { name: clientName, email: clientEmail, phone: clientPhone }, + brandColor, + subscriptionStart: new Date(subscriptionStart), + subscriptionEnd: new Date(subscriptionEnd), + createdBy: req.user.id + }); + + await client.save(); + + let newAccessGate; + + try { + // Auto-discover all boards reachable from root via tile.loadBoard links + const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); + + // Create the access gate + newAccessGate = new AccessGate({ + code: accessGateCode.toUpperCase(), + accessClientId: client._id, + rootBoardId, + linkedBoardIds: linkedBoardIds + }); + + await newAccessGate.save(); + + // Mark all discovered boards with the access gate code + await Board.updateMany( + { _id: { $in: linkedBoardIds } }, + { $set: { accessGateCode: newAccessGate.code } } + ); + } catch (err) { + await AccessClient.deleteOne({ _id: client._id }); + if (newAccessGate?._id) { + await AccessGate.deleteOne({ _id: newAccessGate._id }); + } + throw err; + } + + return res.status(201).json({ ...client.toJSON(), accessGate: newAccessGate.toJSON() }); + } catch (err) { + if (err.code === 11000) { + const isSlugDuplicate = err.message.includes('slug'); + return res.status(409).json({ + message: isSlugDuplicate ? 'Slug already exists' : 'Code already exists' + }); + } + return res.status(500).json({ + message: 'Error creating client', + error: err.message + }); + } +} + +/** + * GET /admin/access/clients + * Lists all Access clients with stats. + * Returns all clients with board counts, expiry status. + * Populates createdBy. Board count derived from AccessGate.linkedBoardIds. + */ +async function listAccessClients(req, res) { + try { + const clients = await AccessClient.find() + .populate('createdBy', 'name email role') + .sort({ createdAt: -1 }); + + // Fetch access gates and sum linkedBoardIds per client to avoid N+1 + const clientIds = clients.map(c => c._id); + const accessGates = await AccessGate.find({ accessClientId: { $in: clientIds } }); + + const agMap = accessGates.reduce((map, ag) => { + const key = ag.accessClientId.toString(); + if (!map[key]) map[key] = 0; + map[key] += ag.linkedBoardIds?.length || 0; + return map; + }, {}); + + const now = new Date(); + const result = clients.map(client => { + const boardCount = agMap[client._id.toString()] || 0; + const daysUntilExpiry = Math.ceil( + (client.subscriptionEnd - now) / (1000 * 60 * 60 * 24) + ); + + return { + ...client.toJSON(), + boardCount, + isExpired: client.subscriptionEnd < now, + daysUntilExpiry: Math.max(0, daysUntilExpiry) + }; + }); + + return res.status(200).json({ + total: result.length, + data: result + }); + } catch (err) { + return res.status(500).json({ + message: 'Error listing clients', + error: err.message + }); + } +} + +/** + * PUT /admin/access/clients/:clientSlug + * Updates an Access client. + * Allowed fields: isActive, subscription dates, branding, client name/email/phone. + */ +async function updateAccessClient(req, res) { + const slug = req.swagger.params.clientSlug.value; + const updates = req.body; + + try { + const client = await AccessClient.findOne({ slug }); + + if (!client) { + return res.status(404).json({ message: 'Client not found' }); + } + + if (updates.isActive !== undefined) client.isActive = updates.isActive; + if (updates.subscriptionStart) + client.subscriptionStart = new Date(updates.subscriptionStart); + if (updates.subscriptionEnd) + client.subscriptionEnd = new Date(updates.subscriptionEnd); + if (updates.clientName) client.contact.name = updates.clientName; + if (updates.clientEmail) client.contact.email = updates.clientEmail; + if (updates.clientPhone) client.contact.phone = updates.clientPhone; + if (updates.brandColor) client.brandColor = updates.brandColor; + + await client.save(); + + return res.status(200).json(client.toJSON()); + } catch (err) { + return res.status(500).json({ + message: 'Error updating client', + error: err.message + }); + } +} + +/** + * GET /admin/access/clients/:clientSlug/stats + * Gets detailed statistics for an Access client. + * Returns client info, access counts (from AccessGates), board list with tile counts. + */ +async function getAccessClientStats(req, res) { + const slug = req.swagger.params.clientSlug.value; + + try { + const client = await AccessClient.findOne({ slug }) + .populate('createdBy', 'name email role'); + + if (!client) { + return res.status(404).json({ message: 'Client not found' }); + } + + const accessGates = await AccessGate.find({ accessClientId: client._id }); + + // Collect unique board IDs across all access gates + const allBoardIds = [ + ...new Set(accessGates.flatMap(ag => ag.linkedBoardIds.map(id => id.toString()))) + ]; + const boards = await Board.find({ _id: { $in: allBoardIds } }, { name: 1, tiles: 1 }); + + const totalAccesses = accessGates.reduce((sum, ag) => sum + (ag.viewsCount || 0), 0); + const lastAccessAt = accessGates.reduce((latest, ag) => { + if (!ag.lastAccessAt) return latest; + if (!latest || ag.lastAccessAt > latest) return ag.lastAccessAt; + return latest; + }, null); + + const now = new Date(); + const daysUntilExpiry = Math.ceil( + (client.subscriptionEnd - now) / (1000 * 60 * 60 * 24) + ); + + return res.status(200).json({ + client: client.toJSON(), + stats: { + totalAccesses, + lastAccessAt, + boardCount: boards.length, + boards: boards.map(b => ({ + id: b._id, + name: b.name, + tilesCount: b.tiles?.length || 0 + })), + daysUntilExpiry: Math.max(0, daysUntilExpiry), + isExpired: client.subscriptionEnd < now + } + }); + } catch (err) { + return res.status(500).json({ + message: 'Error getting client statistics', + error: err.message + }); + } +} + +/** + * PUT /admin/access/gates/:gateCode + * Re-runs board discovery for an access gate, updating its linkedBoardIds. + * Optionally accepts a new rootBoardId to change the root and re-discover from there. + * Useful when the board structure has changed since the access gate was created. + */ +async function updateAccessGate(req, res) { + const code = req.swagger.params.gateCode.value.toUpperCase(); + const { rootBoardId } = req.body; + + try { + const accessGate = await AccessGate.findOne({ code }); + if (!accessGate) { + return res.status(404).json({ message: 'Access gate not found' }); + } + + const newRootBoardId = rootBoardId || accessGate.rootBoardId; + + // Verify root board exists + const rootBoard = await Board.findById(newRootBoardId); + if (!rootBoard) { + return res.status(404).json({ message: 'Root board not found' }); + } + + const oldLinkedBoardIds = accessGate.linkedBoardIds.map(id => id.toString()); + + // Re-discover all boards reachable from root + const linkedBoardIds = await getAllLinkedBoardIds(newRootBoardId); + const newLinkedBoardIds = linkedBoardIds.map(id => id.toString()); + const removedIds = oldLinkedBoardIds.filter(id => !newLinkedBoardIds.includes(id)); + + accessGate.rootBoardId = newRootBoardId; + accessGate.linkedBoardIds = linkedBoardIds; + await accessGate.save(); + + if (removedIds.length) { + await Board.updateMany( + { _id: { $in: removedIds }, accessGateCode: code }, + { $set: { accessGateCode: null } } + ); + } + await Board.updateMany( + { _id: { $in: linkedBoardIds } }, + { $set: { accessGateCode: code } } + ); + + return res.status(200).json(accessGate.toJSON()); + } catch (err) { + return res.status(500).json({ + message: 'Error updating access gate', + error: err.message + }); + } +} \ No newline at end of file diff --git a/api/controllers/board.js b/api/controllers/board.js index 08062db4..5f595204 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -154,7 +154,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true } }, + { query: { ...query, isPublic: true, accessGateCode: null } }, req.query ); diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js new file mode 100644 index 00000000..2ba8e4d2 --- /dev/null +++ b/api/models/AccessClient.js @@ -0,0 +1,74 @@ +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ACCESS_CLIENT_SCHEMA_DEFINITION = { + slug: { + type: String, + unique: true, + required: true, + trim: true, + lowercase: true + }, + contact: { + name: { + type: String, + required: true, + trim: true + }, + email: { + type: String, + trim: true + }, + phone: { + type: String, + trim: true + } + }, + brandColor: { + type: String, + default: '#1976d2' + }, + isActive: { + type: Boolean, + default: true + }, + subscriptionStart: { + type: Date, + required: true + }, + subscriptionEnd: { + type: Date, + required: true + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + } +}; + +const ACCESS_CLIENT_SCHEMA_OPTIONS = { + timestamps: true, + toObject: { + virtuals: true + }, + toJSON: { + virtuals: true, + versionKey: false, + transform: function(doc, ret) { + ret.id = ret._id; + delete ret._id; + } + } +}; + +const accessClientSchema = new Schema( + ACCESS_CLIENT_SCHEMA_DEFINITION, + ACCESS_CLIENT_SCHEMA_OPTIONS +); + +const AccessClient = mongoose.model('AccessClient', accessClientSchema); + +module.exports = AccessClient; diff --git a/api/models/AccessGate.js b/api/models/AccessGate.js new file mode 100644 index 00000000..3899a4fa --- /dev/null +++ b/api/models/AccessGate.js @@ -0,0 +1,82 @@ +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ACCESS_GATE_SCHEMA_DEFINITION = { + code: { + type: String, + required: true, + trim: true, + uppercase: true, + unique: true, + index: true + }, + accessClientId: { + type: Schema.Types.ObjectId, + ref: 'AccessClient', + required: true + }, + rootBoardId: { + type: Schema.Types.ObjectId, + ref: 'Board', + required: true + }, + viewsCount: { + type: Number, + default: 0 + }, + lastAccessAt: { + type: Date, + default: null + }, + linkedBoardIds: { + type: [Schema.Types.ObjectId], + ref: 'Board', + default: [] + } +}; + +const ACCESS_GATE_SCHEMA_OPTIONS = { + timestamps: true, + toObject: { + virtuals: true + }, + toJSON: { + virtuals: true, + versionKey: false, + transform: function(doc, ret) { + ret.id = ret._id; + delete ret._id; + } + } +}; + +const accessGateSchema = new Schema( + ACCESS_GATE_SCHEMA_DEFINITION, + ACCESS_GATE_SCHEMA_OPTIONS +); + +accessGateSchema.virtual('accessClient', { + ref: 'AccessClient', + localField: 'accessClientId', + foreignField: '_id', + justOne: true +}); + +accessGateSchema.virtual('rootBoard', { + ref: 'Board', + localField: 'rootBoardId', + foreignField: '_id', + justOne: true +}); + +accessGateSchema.virtual('linkedBoards', { + ref: 'Board', + localField: 'linkedBoardIds', + foreignField: '_id' +}); + +const AccessGate = mongoose.model('AccessGate', accessGateSchema); + +module.exports = AccessGate; diff --git a/api/models/Board.js b/api/models/Board.js index b316880f..8fdf646e 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -62,7 +62,13 @@ const BOARD_SCHEMA_DEFINITION = { type: Boolean, default: false }, - grid: {} + grid: {}, + accessGateCode: { + type: String, + trim: true, + uppercase: true, + default: null + } }; const BOARD_SCHEMA_OPTIONS = { @@ -85,6 +91,12 @@ const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); boardSchema.index({ email: 1 }); boardSchema.index({ email: 1, lastEdited: 1, _id: 1 }); +// Partial index so boards without a gate code (null/undefined) are not indexed, keeping the index lean. +boardSchema.index( + { accessGateCode: 1 }, + { partialFilterExpression: { accessGateCode: { $type: 'string' } } } +); + const validatePresenceOf = value => value && value.length; /** diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 1e76af57..677979eb 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1327,6 +1327,225 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" + /access/clients/all: + x-swagger-router-controller: access + get: + operationId: getClients + description: Lists active companies for Cboard Access. Returns clients where isActive=true and subscription is valid. Sorted by client name. + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessClientsListResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /access/{clientSlug}/{gateCode}: + x-swagger-router-controller: access + get: + operationId: getAccessBoards + description: Gets ALL boards for an access code in a single request. Validates slug and code exist and subscription is active. Returns client info, all boards array, and rootBoardId. + parameters: + - name: clientSlug + type: string + in: path + required: true + description: Client slug (institution identifier) + - name: gateCode + type: string + in: path + required: true + description: Access gate code for the client + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessBoardResponse" + "404": + description: Invalid or expired access code + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /admin/access/clients: + x-swagger-router-controller: access + post: + operationId: createAccessClient + description: Creates a new Access client and its first AccessGate. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessGateCode. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: client + in: body + description: Access client data for creating a new client + required: true + schema: + $ref: "#/definitions/AccessClientCreate" + responses: + "201": + description: Success - client created + schema: + $ref: "#/definitions/AccessClientResponse" + "404": + description: Root board not found + schema: + $ref: "#/definitions/ErrorResponse" + "409": + description: Code already exists + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + get: + operationId: listAccessClients + description: Lists all Access clients with stats. Returns all clients with board counts and expiry status. Requires admin authentication. + security: + - Bearer: [] + x-security-scopes: + - admin + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessClientsAdminListResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /admin/access/clients/{clientSlug}: + x-swagger-router-controller: access + put: + operationId: updateAccessClient + description: Updates an Access client. Requires admin authentication. Allowed fields include isActive, subscription dates, and branding. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: clientSlug + type: string + in: path + required: true + description: Slug identifier for the client + - name: updates + in: body + description: Fields to update + required: true + schema: + $ref: "#/definitions/AccessClientUpdate" + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessClientResponse" + "404": + description: Client not found + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /admin/access/clients/{clientSlug}/stats: + x-swagger-router-controller: access + get: + operationId: getAccessClientStats + description: Gets detailed statistics for an Access client. Returns client info, access counts, and board list with tile counts. Requires admin authentication. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: clientSlug + type: string + in: path + required: true + description: Slug identifier for the client + responses: + "200": + description: Success + schema: + type: object + properties: + client: + $ref: "#/definitions/AccessClientResponse" + stats: + type: object + properties: + totalAccesses: + type: integer + lastAccessAt: + type: string + format: date-time + boardCount: + type: integer + boards: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + tilesCount: + type: integer + daysUntilExpiry: + type: integer + isExpired: + type: boolean + "404": + description: Client not found + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /admin/access/gates/{gateCode}: + x-swagger-router-controller: access + put: + operationId: updateAccessGate + description: Re-runs board discovery for an access gate, updating its linkedBoardIds. Optionally accepts a new rootBoardId. Requires admin authentication. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: gateCode + type: string + in: path + required: true + description: Access gate code (uppercase) + - name: body + in: body + required: false + schema: + type: object + properties: + rootBoardId: + type: string + description: New root board ID (optional, keeps existing if not provided) + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessGateResponse" + "404": + description: Access gate or root board not found + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" # complex objects have schema definitions definitions: AnalyticsReport: @@ -1729,6 +1948,10 @@ definitions: type: string format: date-time description: Last edition time for the board + accessGateCode: + type: string + x-nullable: true + description: The access gate code associated with this board required: - id - name @@ -1865,6 +2088,10 @@ definitions: type: string format: date-time description: Last edition time for the board + accessGateCode: + type: string + x-nullable: true + description: The access gate code associated with this board required: - id - name @@ -2050,4 +2277,328 @@ definitions: - phrase properties: phrase: - type: string \ No newline at end of file + type: string + AccessClientCreate: + type: object + required: + - slug + - clientName + - rootBoardId + - subscriptionStart + - subscriptionEnd + - accessGateCode + properties: + slug: + type: string + description: Unique slug identifier for the access client (lowercase) + example: my-company + accessGateCode: + type: string + description: Unique code for the access gate (will be uppercased) + example: ABC123 + clientName: + type: string + description: Name of the client organization + example: Company Name + clientEmail: + type: string + description: Email of the client organization + clientPhone: + type: string + description: Phone number of the client organization + brandColor: + type: string + description: Brand color for the client (hex color) + example: "#FF5733" + rootBoardId: + type: string + description: ID of the root board (boards reachable from it are auto-discovered) + subscriptionStart: + type: string + format: date-time + description: Subscription start date + subscriptionEnd: + type: string + format: date-time + description: Subscription end date + AccessClientUpdate: + type: object + properties: + isActive: + type: boolean + description: Whether the client is active + subscriptionStart: + type: string + format: date-time + description: Subscription start date + subscriptionEnd: + type: string + format: date-time + description: Subscription end date + clientName: + type: string + description: Name of the client organization + clientEmail: + type: string + description: Email of the client + clientPhone: + type: string + description: Phone number of the client + brandColor: + type: string + description: Brand color for the client (hex color) + AccessClientResponse: + type: object + properties: + id: + type: string + description: Client ID + slug: + type: string + description: Unique slug identifier (lowercase) + contact: + type: object + properties: + name: + type: string + description: Client organization name + email: + type: string + description: Email of the client + phone: + type: string + description: Phone number of the client + brandColor: + type: string + description: Brand color (hex) + isActive: + type: boolean + description: Whether client is active + subscriptionStart: + type: string + format: date-time + description: Subscription start date + subscriptionEnd: + type: string + format: date-time + description: Subscription end date + createdBy: + description: User who created the client (string ID or populated user object with name, email, role) + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + accessGate: + description: Present only on create response + $ref: "#/definitions/AccessGateResponse" + AccessClientsListResponse: + type: object + required: + - total + - data + properties: + total: + type: integer + description: Total number of clients + data: + type: array + items: + type: object + properties: + slug: + type: string + description: Client slug identifier + clientName: + type: string + description: Client name + brandColor: + type: string + description: Brand color (hex) + accessGates: + type: array + items: + type: object + properties: + code: + type: string + description: Access gate code + rootBoard: + type: object + properties: + id: + type: string + name: + type: string + caption: + type: string + tilesCount: + type: integer + AccessClientsAdminListResponse: + type: object + required: + - total + - data + properties: + total: + type: integer + description: Total number of clients + data: + type: array + items: + type: object + properties: + id: + type: string + slug: + type: string + contact: + type: object + properties: + name: + type: string + email: + type: string + phone: + type: string + brandColor: + type: string + isActive: + type: boolean + subscriptionStart: + type: string + format: date-time + subscriptionEnd: + type: string + format: date-time + createdBy: + type: object + description: Populated user object who created the client + properties: + name: + type: string + email: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + boardCount: + type: integer + description: Total boards across all access gates for this client + isExpired: + type: boolean + description: Whether the subscription has expired + daysUntilExpiry: + type: integer + description: Days until subscription expires (0 if expired) + AccessBoardResponse: + type: object + required: + - client + - boards + - rootBoardId + properties: + client: + type: object + properties: + slug: + type: string + description: Client slug identifier + name: + type: string + description: Client name + color: + type: string + description: Brand color (hex) + boards: + type: array + items: + $ref: "#/definitions/AccessBoard" + description: All boards associated with this access code + rootBoardId: + type: string + description: ID of the root board + AccessBoard: + type: object + description: Board data returned for access endpoints (excludes PII fields like author and email) + required: + - id + - name + - tiles + properties: + id: + type: string + name: + type: string + tiles: + type: array + items: + type: object + caption: + type: string + isPublic: + type: boolean + lastEdited: + type: string + format: date-time + description: + type: string + locale: + type: string + isFixed: + type: boolean + grid: + type: object + accessGateCode: + type: string + x-nullable: true + description: The access gate code associated with this board + AccessGateResponse: + type: object + properties: + id: + type: string + code: + type: string + description: Access gate code (uppercase) + accessClientId: + type: string + description: AccessClient ID (foreign key) + accessClient: + type: object + description: Populated AccessClient document (present when populated via virtual) + rootBoardId: + type: string + description: Root board ID (foreign key) + rootBoard: + type: object + description: Populated root Board document (present when populated via virtual) + linkedBoardIds: + type: array + items: + type: string + description: All board IDs reachable from the root board + linkedBoards: + type: array + items: + type: object + description: Populated linked Board documents (present when populated via virtual) + viewsCount: + type: integer + description: Number of times this access gate has been accessed + lastAccessAt: + type: string + format: date-time + description: Last access timestamp + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time diff --git a/scripts/qr-generator/generate-qr.js b/scripts/qr-generator/generate-qr.js new file mode 100644 index 00000000..95387342 --- /dev/null +++ b/scripts/qr-generator/generate-qr.js @@ -0,0 +1,128 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const zlib = require('zlib'); +const vm = require('vm'); + +require('dotenv').config(); + +const src = fs.readFileSync(path.join(__dirname, 'qrcodegen-v1.8.0-es6.js'), 'utf8'); +const sandbox = {}; +vm.runInNewContext(src, sandbox); +const { qrcodegen } = sandbox; + +const DEFAULT_BASE_URL = process.env.CBOARD_APP_URL || 'https://app.cboard.io'; +const QR_SCALE = 10; +const QR_MARGIN = 4; + +// CRC32 table for PNG chunk checksums +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[i] = c; + } + return t; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const typeBytes = Buffer.from(type, 'ascii'); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crcInput = Buffer.concat([typeBytes, data]); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(crcInput), 0); + return Buffer.concat([len, typeBytes, data, crcBuf]); +} + +function encodeGrayscalePng(pixels, width, height) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 0; // grayscale + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + + // Each scanline: 1 filter byte (None=0) + width pixel bytes + const rows = []; + for (let y = 0; y < height; y++) { + const row = Buffer.alloc(width + 1); + row[0] = 0; + pixels.copy(row, 1, y * width, y * width + width); + rows.push(row); + } + const compressed = zlib.deflateSync(Buffer.concat(rows)); + + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), // PNG signature + pngChunk('IHDR', ihdr), + pngChunk('IDAT', compressed), + pngChunk('IEND', Buffer.alloc(0)) + ]); +} + +function generateQRCode(code, slug, baseUrl = DEFAULT_BASE_URL) { + if (!code) throw new Error('Access code is required'); + if (!slug) throw new Error('Client slug is required'); + + const normalizedCode = code.toUpperCase(); + const url = `${baseUrl}/access/${slug}/${normalizedCode}`; + const filename = `${slug}-${normalizedCode}-qr.png`; + const outputPath = path.join(process.cwd(), filename); + + const qr = qrcodegen.QrCode.encodeText(url, qrcodegen.QrCode.Ecc.MEDIUM); + const imgSize = (qr.size + QR_MARGIN * 2) * QR_SCALE; + const pixels = Buffer.alloc(imgSize * imgSize, 0xff); // white background + + for (let y = 0; y < qr.size; y++) { + for (let x = 0; x < qr.size; x++) { + if (qr.getModule(x, y)) { + for (let dy = 0; dy < QR_SCALE; dy++) { + const py = (QR_MARGIN + y) * QR_SCALE + dy; + const px = (QR_MARGIN + x) * QR_SCALE; + pixels.fill(0x00, py * imgSize + px, py * imgSize + px + QR_SCALE); + } + } + } + } + + fs.writeFileSync(outputPath, encodeGrayscalePng(pixels, imgSize, imgSize)); + return Promise.resolve({ url, outputPath }); +} + +if (require.main === module) { + const args = process.argv.slice(2); + const slug = args[0]; + const code = args[1]; + const baseUrl = args[2] || DEFAULT_BASE_URL; + + if (!slug || !code) { + console.error('Error: Client slug and access code are required'); + console.error('\nUsage: node scripts/qr-generator/generate-qr.js [BASE_URL]'); + console.error('Example: node scripts/qr-generator/generate-qr.js my-company ABC123 https://app.cboard.io'); + process.exit(1); + } + + generateQRCode(code, slug, baseUrl) + .then(({ url, outputPath }) => { + console.log('QR code generated successfully!'); + console.log(' URL encoded:', url); + console.log(' Output file:', outputPath); + }) + .catch(error => { + console.error('Error generating QR code:', error.message); + process.exit(1); + }); +} + +module.exports = { generateQRCode }; diff --git a/scripts/qr-generator/qrcodegen-v1.8.0-es6.js b/scripts/qr-generator/qrcodegen-v1.8.0-es6.js new file mode 100644 index 00000000..070eaa0d --- /dev/null +++ b/scripts/qr-generator/qrcodegen-v1.8.0-es6.js @@ -0,0 +1,837 @@ +/* + * QR Code generator library (compiled from TypeScript) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ +"use strict"; +var qrcodegen; +(function (qrcodegen) { + /*---- QR Code symbol class ----*/ + /* + * A QR Code symbol, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * Instances of this class represent an immutable square grid of dark and light cells. + * The class provides static factory functions to create a QR Code from text or binary data. + * The class covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary(). + * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments(). + * - Low level: Custom-make the array of data codeword bytes (including + * segment headers and final padding, excluding error correction codewords), + * supply the appropriate version number, and call the QrCode() constructor. + * (Note that all ways require supplying the desired error correction level.) + */ + class QrCode { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + constructor( + // The version number of this QR Code, which is between 1 and 40 (inclusive). + // This determines the size of this barcode. + version, + // The error correction level used in this QR Code. + errorCorrectionLevel, dataCodewords, msk) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + this.modules = []; + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + this.isFunction = []; + // Check scalar arguments + if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) + throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + // Initialize both grids to be size*size arrays of Boolean false + let row = []; + for (let i = 0; i < this.size; i++) + row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); // Initially all light + this.isFunction.push(row.slice()); + } + // Compute ECC, draw modules + this.drawFunctionPatterns(); + const allCodewords = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + // Do masking + if (msk == -1) { // Automatically choose best mask + let minPenalty = 1000000000; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); // Undoes the mask due to XOR + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); // Apply the final choice of mask + this.drawFormatBits(msk); // Overwrite old format bits + this.isFunction = []; + } + /*-- Static factory functions (high level) --*/ + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + static encodeText(text, ecl) { + const segs = qrcodegen.QrSegment.makeSegments(text); + return QrCode.encodeSegments(segs, ecl); + } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + static encodeBinary(data, ecl) { + const seg = qrcodegen.QrSegment.makeBytes(data); + return QrCode.encodeSegments([seg], ecl); + } + /*-- Static factory functions (mid level) --*/ + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) { + if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION) + || mask < -1 || mask > 7) + throw new RangeError("Invalid value"); + // Find the minimal version number to use + let version; + let dataUsedBits; + for (version = minVersion;; version++) { + const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available + const usedBits = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits) { + dataUsedBits = usedBits; + break; // This version number is found to be suitable + } + if (version >= maxVersion) // All versions in the range could not fit the given data + throw new RangeError("Data too long"); + } + // Increase the error correction level while the data still fits in the current version number + for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high + if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + // Concatenate all segments to create the data bit string + let bb = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) + bb.push(b); + } + assert(bb.length == dataUsedBits); + // Add terminator and pad up to a byte if applicable + const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - bb.length % 8) % 8, bb); + assert(bb.length % 8 == 0); + // Pad with alternating bytes until data capacity is reached + for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11) + appendBits(padByte, 8, bb); + // Pack bits into bytes in big endian + let dataCodewords = []; + while (dataCodewords.length * 8 < bb.length) + dataCodewords.push(0); + bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << (7 - (i & 7))); + // Create the QR Code object + return new QrCode(version, ecl, dataCodewords, mask); + } + /*-- Accessor methods --*/ + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + getModule(x, y) { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + /*-- Private helper methods for constructor: Drawing function modules --*/ + // Reads this object's version field, and draws and marks all function modules. + drawFunctionPatterns() { + // Draw horizontal and vertical timing patterns + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + // Draw numerous alignment patterns + const alignPatPos = this.getAlignmentPatternPositions(); + const numAlign = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + // Draw configuration data + this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor + this.drawVersion(); + } + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + drawFormatBits(mask) { + // Calculate error correction code and pack bits + const data = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3 + let rem = data; + for (let i = 0; i < 10; i++) + rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = (data << 10 | rem) ^ 0x5412; // uint15 + assert(bits >>> 15 == 0); + // Draw first copy + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + // Draw second copy + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); // Always dark + } + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + drawVersion() { + if (this.version < 7) + return; + // Calculate error correction code and pack bits + let rem = this.version; // version is uint6, in the range [7, 40] + for (let i = 0; i < 12; i++) + rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25); + const bits = this.version << 12 | rem; // uint18 + assert(bits >>> 18 == 0); + // Draw two copies + for (let i = 0; i < 18; i++) { + const color = getBit(bits, i); + const a = this.size - 11 + i % 3; + const b = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + drawFinderPattern(x, y) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm + const xx = x + dx; + const yy = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + drawAlignmentPattern(x, y) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + setFunctionModule(x, y, isDark) { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + /*-- Private helper methods for constructor: Codewords and masking --*/ + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + addEccAndInterleave(data) { + const ver = this.version; + const ecl = this.errorCorrectionLevel; + if (data.length != QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + // Calculate parameter numbers + const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - rawCodewords % numBlocks; + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + // Split data into blocks and append ECC to each block + let blocks = []; + const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) + dat.push(0); + blocks.push(dat.concat(ecc)); + } + // Interleave (not concatenate) the bytes from every block into a single sequence + let result = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + // Skip the padding byte in short blocks + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + drawCodewords(data) { + if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i = 0; // Bit index into the data + // Do the funny zigzag scan + for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair + if (right == 6) + right = 5; + for (let vert = 0; vert < this.size; vert++) { // Vertical counter + for (let j = 0; j < 2; j++) { + const x = right - j; // Actual x coordinate + const upward = ((right + 1) & 2) == 0; + const y = upward ? this.size - 1 - vert : vert; // Actual y coordinate + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == data.length * 8); + } + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + applyMask(mask) { + if (mask < 0 || mask > 7) + throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + getPenaltyScore() { + let result = 0; + // Adjacent modules in row having same color, and finder-like patterns + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) + result += QrCode.PENALTY_N1; + else if (runX > 5) + result++; + } + else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) + result += QrCode.PENALTY_N1; + else if (runY > 5) + result++; + } + else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3; + } + // 2*2 blocks of modules having same color + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color = this.modules[y][x]; + if (color == this.modules[y][x + 1] && + color == this.modules[y + 1][x] && + color == this.modules[y + 1][x + 1]) + result += QrCode.PENALTY_N2; + } + } + // Balance of dark and light modules + let dark = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total = this.size * this.size; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; + } + /*-- Private helper functions --*/ + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + getAlignmentPatternPositions() { + if (this.version == 1) + return []; + else { + const numAlign = Math.floor(this.version / 7) + 2; + const step = (this.version == 32) ? 26 : + Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + static getNumRawDataModules(ver) { + if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + static getNumDataCodewords(ver, ecl) { + return Math.floor(QrCode.getNumRawDataModules(ver) / 8) - + QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * + QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + } + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + static reedSolomonComputeDivisor(degree) { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93]. + let result = []; + for (let i = 0; i < degree - 1; i++) + result.push(0); + result.push(1); // Start off with the monomial x^0 + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // and drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + let root = 1; + for (let i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (let j = 0; j < result.length; j++) { + result[j] = QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) + result[j] ^= result[j + 1]; + } + root = QrCode.reedSolomonMultiply(root, 0x02); + } + return result; + } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + static reedSolomonComputeRemainder(data, divisor) { + let result = divisor.map(_ => 0); + for (const b of data) { // Polynomial division + const factor = b ^ result.shift(); + result.push(0); + divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor)); + } + return result; + } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + static reedSolomonMultiply(x, y) { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + // Russian peasant multiplication + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11D); + z ^= ((y >>> i) & 1) * x; + } + assert(z >>> 8 == 0); + return z; + } + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + finderPenaltyCountPatterns(runHistory) { + const n = runHistory[1]; + assert(n <= this.size * 3); + const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); + } + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) { + if (currentRunColor) { // Terminate dark run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; // Add light border to final run + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + finderPenaltyAddHistory(currentRunLength, runHistory) { + if (runHistory[0] == 0) + currentRunLength += this.size; // Add light border to initial run + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + } + /*-- Constants and tables --*/ + // The minimum version number supported in the QR Code Model 2 standard. + QrCode.MIN_VERSION = 1; + // The maximum version number supported in the QR Code Model 2 standard. + QrCode.MAX_VERSION = 40; + // For use in getPenaltyScore(), when evaluating which mask is best. + QrCode.PENALTY_N1 = 3; + QrCode.PENALTY_N2 = 3; + QrCode.PENALTY_N3 = 40; + QrCode.PENALTY_N4 = 10; + QrCode.ECC_CODEWORDS_PER_BLOCK = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High + ]; + QrCode.NUM_ERROR_CORRECTION_BLOCKS = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High + ]; + qrcodegen.QrCode = QrCode; + // Appends the given number of low-order bits of the given value + // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len. + function appendBits(val, len, bb) { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for (let i = len - 1; i >= 0; i--) // Append bit by bit + bb.push((val >>> i) & 1); + } + // Returns true iff the i'th bit of x is set to 1. + function getBit(x, i) { + return ((x >>> i) & 1) != 0; + } + // Throws an exception if the given condition is false. + function assert(cond) { + if (!cond) + throw new Error("Assertion error"); + } + /*---- Data segment class ----*/ + /* + * A segment of character/binary/control data in a QR Code symbol. + * Instances of this class are immutable. + * The mid-level way to create a segment is to take the payload data + * and call a static factory function such as QrSegment.makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and call the QrSegment() constructor with appropriate values. + * This segment class imposes no length restrictions, but QR Codes have restrictions. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + */ + class QrSegment { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + constructor( + // The mode indicator of this segment. + mode, + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + numChars, + // The data bits of this segment. Accessed through getData(). + bitData) { + this.mode = mode; + this.numChars = numChars; + this.bitData = bitData; + if (numChars < 0) + throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); // Make defensive copy + } + /*-- Static factory functions (mid level) --*/ + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + static makeBytes(data) { + let bb = []; + for (const b of data) + appendBits(b, 8, bb); + return new QrSegment(QrSegment.Mode.BYTE, data.length, bb); + } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + static makeNumeric(digits) { + if (!QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb = []; + for (let i = 0; i < digits.length;) { // Consume up to 3 digits per iteration + const n = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substr(i, n), 10), n * 3 + 1, bb); + i += n; + } + return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb); + } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static makeAlphanumeric(text) { + if (!QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb = []; + let i; + for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2 + let temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) // 1 character remaining + appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + static makeSegments(text) { + // Select the most efficient segment encoding automatically + if (text == "") + return []; + else if (QrSegment.isNumeric(text)) + return [QrSegment.makeNumeric(text)]; + else if (QrSegment.isAlphanumeric(text)) + return [QrSegment.makeAlphanumeric(text)]; + else + return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))]; + } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + static makeEci(assignVal) { + let bb = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < (1 << 7)) + appendBits(assignVal, 8, bb); + else if (assignVal < (1 << 14)) { + appendBits(0b10, 2, bb); + appendBits(assignVal, 14, bb); + } + else if (assignVal < 1000000) { + appendBits(0b110, 3, bb); + appendBits(assignVal, 21, bb); + } + else + throw new RangeError("ECI assignment value out of range"); + return new QrSegment(QrSegment.Mode.ECI, 0, bb); + } + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + static isNumeric(text) { + return QrSegment.NUMERIC_REGEX.test(text); + } + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static isAlphanumeric(text) { + return QrSegment.ALPHANUMERIC_REGEX.test(text); + } + /*-- Methods --*/ + // Returns a new copy of the data bits of this segment. + getData() { + return this.bitData.slice(); // Make defensive copy + } + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + static getTotalBits(segs, version) { + let result = 0; + for (const seg of segs) { + const ccbits = seg.mode.numCharCountBits(version); + if (seg.numChars >= (1 << ccbits)) + return Infinity; // The segment's length doesn't fit the field's bit width + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + // Returns a new array of bytes representing the given string encoded in UTF-8. + static toUtf8ByteArray(str) { + str = encodeURI(str); + let result = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") + result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substr(i + 1, 2), 16)); + i += 2; + } + } + return result; + } + } + /*-- Constants --*/ + // Describes precisely all strings that are encodable in numeric mode. + QrSegment.NUMERIC_REGEX = /^[0-9]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + QrSegment.ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/; + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + QrSegment.ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + qrcodegen.QrSegment = QrSegment; +})(qrcodegen || (qrcodegen = {})); +/*---- Public helper enumeration ----*/ +(function (qrcodegen) { + var QrCode; + (function (QrCode) { + /* + * The error correction level in a QR Code symbol. Immutable. + */ + class Ecc { + /*-- Constructor and fields --*/ + constructor( + // In the range 0 to 3 (unsigned 2-bit integer). + ordinal, + // (Package-private) In the range 0 to 3 (unsigned 2-bit integer). + formatBits) { + this.ordinal = ordinal; + this.formatBits = formatBits; + } + } + /*-- Constants --*/ + Ecc.LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords + Ecc.MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords + Ecc.QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords + Ecc.HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords + QrCode.Ecc = Ecc; + })(QrCode = qrcodegen.QrCode || (qrcodegen.QrCode = {})); +})(qrcodegen || (qrcodegen = {})); +/*---- Public helper enumeration ----*/ +(function (qrcodegen) { + var QrSegment; + (function (QrSegment) { + /* + * Describes how a segment's data bits are interpreted. Immutable. + */ + class Mode { + /*-- Constructor and fields --*/ + constructor( + // The mode indicator bits, which is a uint4 value (range 0 to 15). + modeBits, + // Number of character count bits for three different version ranges. + numBitsCharCount) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } + /*-- Method --*/ + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + numCharCountBits(ver) { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + } + /*-- Constants --*/ + Mode.NUMERIC = new Mode(0x1, [10, 12, 14]); + Mode.ALPHANUMERIC = new Mode(0x2, [9, 11, 13]); + Mode.BYTE = new Mode(0x4, [8, 16, 16]); + Mode.KANJI = new Mode(0x8, [8, 10, 12]); + Mode.ECI = new Mode(0x7, [0, 0, 0]); + QrSegment.Mode = Mode; + })(QrSegment = qrcodegen.QrSegment || (qrcodegen.QrSegment = {})); +})(qrcodegen || (qrcodegen = {})); diff --git a/test/controllers/access.js b/test/controllers/access.js new file mode 100644 index 00000000..a4bc61ec --- /dev/null +++ b/test/controllers/access.js @@ -0,0 +1,638 @@ +//During the test the env variable is set to test +process.env.NODE_ENV = 'test'; + +//Require the dev-dependencies +const chai = require('chai'); +var request = require('supertest'); +var expect = require('chai').expect; +const should = chai.should(); +const helper = require('../helper'); + +const AccessClient = require('../../api/models/AccessClient'); +const AccessGate = require('../../api/models/AccessGate'); +const Board = require('../../api/models/Board'); + +//Parent block +describe('Access API calls', function () { + let adminUser; + let regularUser; + let server; + let testBoardId; + let testBoardId2; + + before(async function () { + helper.prepareNodemailerMock(); //enable mockery and replace nodemailer with nodemailerMock + server = require('../../app'); //register mocks before require the original dependency + }); + + beforeEach(async function () { + this.timeout(10000); + // Create admin user + adminUser = await helper.prepareUser(server, { + role: 'admin', + email: helper.generateEmail(), + }); + + // Create regular user + regularUser = await helper.prepareUser(server, { + role: 'user', + email: helper.generateEmail(), + }); + + // Create test boards + testBoardId = await helper.createMochaBoard(server, adminUser.token, adminUser.email); + testBoardId2 = await helper.createMochaBoard(server, adminUser.token, adminUser.email); + + const clients = await AccessClient.find({ 'contact.name': /mocha test/i }); + const clientIds = clients.map(c => c._id); + await AccessGate.deleteMany({ accessClientId: { $in: clientIds } }); + await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); + }); + + after(async function () { + helper.prepareNodemailerMock(true); //disable mockery + await helper.deleteMochaUsers(); + await Board.deleteMany({ author: 'cboard mocha test' }); + const clients = await AccessClient.find({ 'contact.name': /mocha test/i }); + const clientIds = clients.map(c => c._id); + await AccessGate.deleteMany({ accessClientId: { $in: clientIds } }); + await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); + }); + + describe('POST /admin/access/clients', function () { + it('it should CREATE an access client and auto-discover linked boards', async function () { + const clientData = { + slug: 'test-01', + clientName: 'Test Client mocha test', + clientContact: 'test@example.com', + brandColor: '#FF5733', + rootBoardId: testBoardId, + accessGateCode: 'TEST01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }; + + const res = await request(server) + .post('/admin/access/clients') + .send(clientData) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(201); + + res.body.should.be.a('object'); + res.body.should.have.property('slug').eql('test-01'); + res.body.contact.should.have.property('name').eql('Test Client mocha test'); + res.body.should.have.property('brandColor').eql('#FF5733'); + res.body.should.have.property('createdBy'); + res.body.should.have.property('accessGate'); + res.body.accessGate.should.have.property('code').eql('TEST01'); + // rootBoard has no tile links, so discovery returns just the root + res.body.accessGate.linkedBoardIds.length.should.eql(1); + + // Verify board was marked with accessGate + const board = await Board.findById(testBoardId); + board.accessGateCode.should.eql('TEST01'); + }); + + it('it should NOT CREATE with duplicate slug', async function () { + const clientData = { + slug: 'duplicate', + clientName: 'Test Client mocha test', + rootBoardId: testBoardId, + accessGateCode: 'DUPL01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }; + + // Create first client + await request(server) + .post('/admin/access/clients') + .send(clientData) + .set('Authorization', `Bearer ${adminUser.token}`) + .expect(201); + + // Try to create with same slug + const res = await request(server) + .post('/admin/access/clients') + .send({ ...clientData, accessGateCode: 'DUPL02' }) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(409); + + res.body.should.have.property('message').eql('Slug already exists'); + }); + + it('it should NOT CREATE with duplicate access gate code', async function () { + const clientData = { + slug: 'duplicate-code-a', + clientName: 'Test Client mocha test', + rootBoardId: testBoardId, + accessGateCode: 'DUPCODE', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }; + + // Create first client + await request(server) + .post('/admin/access/clients') + .send(clientData) + .set('Authorization', `Bearer ${adminUser.token}`) + .expect(201); + + // Try to create with same accessGate but different slug + const res = await request(server) + .post('/admin/access/clients') + .send({ ...clientData, slug: 'duplicate-code-b' }) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(409); + + res.body.should.have.property('message').eql('Code already exists'); + }); + + it('it should NOT CREATE with non-existent rootBoardId', async function () { + const clientData = { + slug: 'test-04', + clientName: 'Test Client mocha test', + rootBoardId: '507f1f77bcf86cd799439011', // Non-existent ID + accessGateCode: 'TEST04', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }; + + const res = await request(server) + .post('/admin/access/clients') + .send(clientData) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Root board not found'); + }); + + it('it should NOT CREATE without authorization', async function () { + const clientData = { + slug: 'test-05', + clientName: 'Test Client mocha test', + rootBoardId: testBoardId, + accessGateCode: 'TEST05', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }; + + await request(server) + .post('/admin/access/clients') + .send(clientData) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('GET /admin/access/clients', function () { + beforeEach(async function () { + // Create test clients + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'list-01', + clientName: 'List Test 1 mocha test', + rootBoardId: testBoardId, + accessGateCode: 'LIST01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + }) + .set('Authorization', `Bearer ${adminUser.token}`); + + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'list-02', + clientName: 'List Test 2 mocha test', + rootBoardId: testBoardId, + accessGateCode: 'LIST02', + subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), // Expired + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should LIST all access clients with stats', async function () { + const res = await request(server) + .get('/admin/access/clients') + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.be.a('object'); + res.body.should.have.property('total'); + res.body.should.have.property('data').that.is.an('array'); + res.body.data.length.should.be.at.least(2); + + // Test boards have no tile links, so auto-discovery returns just root = 1 board + const client1 = res.body.data.find((c) => c.slug === 'list-01'); + client1.should.have.property('boardCount').eql(1); + client1.should.have.property('isExpired').eql(false); + client1.should.have.property('daysUntilExpiry'); + client1.daysUntilExpiry.should.be.at.least(0); + + // Check expired client + const client2 = res.body.data.find((c) => c.slug === 'list-02'); + client2.should.have.property('isExpired').eql(true); + client2.should.have.property('daysUntilExpiry').eql(0); // Should be clamped at 0 + }); + + it('it should NOT LIST without authorization', async function () { + await request(server) + .get('/admin/access/clients') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('PUT /admin/access/clients/:slug', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'update-01', + clientName: 'Update Test mocha test', + rootBoardId: testBoardId, + accessGateCode: 'UPDATE01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + brandColor: '#000000', + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should UPDATE an access client', async function () { + const updates = { + clientName: 'Updated Name mocha test', + brandColor: '#FFFFFF', + isActive: false, + }; + + const res = await request(server) + .put('/admin/access/clients/update-01') + .send(updates) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.contact.should.have.property('name').eql('Updated Name mocha test'); + res.body.should.have.property('brandColor').eql('#FFFFFF'); + res.body.should.have.property('isActive').eql(false); + }); + + it('it should UPDATE subscription dates', async function () { + const newEndDate = new Date(Date.now() + 730 * 24 * 60 * 60 * 1000); // 2 years + const updates = { + subscriptionEnd: newEndDate, + }; + + const res = await request(server) + .put('/admin/access/clients/update-01') + .send(updates) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + const actualDate = new Date(res.body.subscriptionEnd); + actualDate.getTime().should.be.closeTo(newEndDate.getTime(), 1000); + }); + + it('it should return 404 for non-existent slug', async function () { + const res = await request(server) + .put('/admin/access/clients/nonexistent') + .send({ clientName: 'Test' }) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Client not found'); + }); + + it('it should NOT UPDATE without authorization', async function () { + await request(server) + .put('/admin/access/clients/update-01') + .send({ clientName: 'Test' }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('PUT /admin/access/gates/:code', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'ap-update-01', + clientName: 'Access Gate Update Test mocha test', + rootBoardId: testBoardId, + accessGateCode: 'APUPDATE01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should re-discover boards when updating access gate root', async function () { + const res = await request(server) + .put('/admin/access/gates/APUPDATE01') + .send({ rootBoardId: testBoardId2 }) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('code').eql('APUPDATE01'); + res.body.should.have.property('linkedBoardIds').that.is.an('array'); + res.body.linkedBoardIds.should.include(testBoardId2.toString()); + + // Verify new root board was marked with accessGate + const board = await Board.findById(testBoardId2); + board.accessGateCode.should.eql('APUPDATE01'); + }); + + it('it should clear accessGateCode from boards no longer linked after root change', async function () { + // testBoardId was the original root, so it has accessGateCode set + const before = await Board.findById(testBoardId); + before.accessGateCode.should.eql('APUPDATE01'); + + // Switch root to testBoardId2 — testBoardId should lose its accessGateCode + await request(server) + .put('/admin/access/gates/APUPDATE01') + .send({ rootBoardId: testBoardId2 }) + .set('Authorization', `Bearer ${adminUser.token}`) + .expect(200); + + const delinked = await Board.findById(testBoardId); + expect(delinked.accessGateCode).to.be.null; + + const linked = await Board.findById(testBoardId2); + linked.accessGateCode.should.eql('APUPDATE01'); + }); + + it('it should re-discover without changing root when no rootBoardId provided', async function () { + const res = await request(server) + .put('/admin/access/gates/APUPDATE01') + .send({}) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('code').eql('APUPDATE01'); + res.body.linkedBoardIds.should.include(testBoardId.toString()); + }); + + it('it should return 404 for non-existent access gate code', async function () { + const res = await request(server) + .put('/admin/access/gates/NONEXISTENT') + .send({}) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Access gate not found'); + }); + + it('it should NOT UPDATE without authorization', async function () { + await request(server) + .put('/admin/access/gates/APUPDATE01') + .send({}) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('GET /admin/access/clients/:slug/stats', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'stats-01', + clientName: 'Stats Test mocha test', + rootBoardId: testBoardId, + accessGateCode: 'STATS01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should GET client statistics', async function () { + const res = await request(server) + .get('/admin/access/clients/stats-01/stats') + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.be.a('object'); + res.body.should.have.property('client'); + res.body.should.have.property('stats'); + + // Verify client data + res.body.client.should.have.property('slug').eql('stats-01'); + res.body.client.contact.should.have.property('name').eql('Stats Test mocha test'); + + // Verify stats — test board has no tile links, so auto-discovery returns 1 board + res.body.stats.should.have.property('totalAccesses').eql(0); + res.body.stats.should.have.property('boardCount').eql(1); + res.body.stats.should.have.property('boards').that.is.an('array'); + res.body.stats.boards.length.should.eql(1); + res.body.stats.should.have.property('daysUntilExpiry'); + res.body.stats.daysUntilExpiry.should.be.at.least(0); + res.body.stats.should.have.property('isExpired').eql(false); + + // Verify board details + res.body.stats.boards[0].should.have.property('id'); + res.body.stats.boards[0].should.have.property('name'); + res.body.stats.boards[0].should.have.property('tilesCount'); + }); + + it('it should return 0 daysUntilExpiry for expired client', async function () { + // Create expired client + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'expired-01', + clientName: 'Expired Test mocha test', + rootBoardId: testBoardId, + accessGateCode: 'EXPIRED01', + subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), + subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), + }) + .set('Authorization', `Bearer ${adminUser.token}`); + + const res = await request(server) + .get('/admin/access/clients/expired-01/stats') + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.stats.should.have.property('isExpired').eql(true); + res.body.stats.should.have.property('daysUntilExpiry').eql(0); // Clamped at 0 + }); + + it('it should return 404 for non-existent slug', async function () { + const res = await request(server) + .get('/admin/access/clients/nonexistent/stats') + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Client not found'); + }); + + it('it should NOT GET stats without authorization', async function () { + await request(server) + .get('/admin/access/clients/stats-01/stats') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('GET /access/:clientSlug/:gateCode', function () { + // The controller updates viewsCount/lastAccessAt fire-and-forget, so tests + // that assert on those fields need a tiny delay for the async write to land. + const waitForAnalytics = () => new Promise(resolve => setTimeout(resolve, 100)); + + beforeEach(async function () { + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'public-01', + clientName: 'Public Access mocha test', + rootBoardId: testBoardId, + accessGateCode: 'PUBLIC01', + brandColor: '#123456', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should GET boards for a valid slug + code pair', async function () { + const res = await request(server) + .get('/access/public-01/PUBLIC01') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('client'); + res.body.client.should.have.property('slug').eql('public-01'); + res.body.client.should.have.property('name').eql('Public Access mocha test'); + res.body.client.should.have.property('color').eql('#123456'); + res.body.should.have.property('boards').that.is.an('array'); + res.body.boards.length.should.be.at.least(1); + res.body.should.have.property('rootBoardId').eql(testBoardId.toString()); + }); + + it('it should accept a lowercase gate code (case-insensitive)', async function () { + await request(server) + .get('/access/public-01/public01') + .set('Accept', 'application/json') + .expect(200); + }); + + it('it should increment viewsCount and update lastAccessAt', async function () { + const before = await AccessGate.findOne({ code: 'PUBLIC01' }); + const startCount = before.viewsCount || 0; + + await request(server) + .get('/access/public-01/PUBLIC01') + .set('Accept', 'application/json') + .expect(200); + + await waitForAnalytics(); + + const after = await AccessGate.findOne({ code: 'PUBLIC01' }); + after.viewsCount.should.eql(startCount + 1); + should.exist(after.lastAccessAt); + if (before.lastAccessAt) { + after.lastAccessAt.getTime().should.be.at.least(before.lastAccessAt.getTime()); + } + }); + + it('it should return 404 for a non-existent gate code', async function () { + const res = await request(server) + .get('/access/public-01/NOPE') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Invalid access code'); + }); + + it('it should return 404 when slug does not match the gate\'s client', async function () { + // Create a second client so its slug exists but does not own PUBLIC01 + await request(server) + .post('/admin/access/clients') + .send({ + slug: 'other-01', + clientName: 'Other Client mocha test', + rootBoardId: testBoardId2, + accessGateCode: 'OTHER01', + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }) + .set('Authorization', `Bearer ${adminUser.token}`); + + const res = await request(server) + .get('/access/other-01/PUBLIC01') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Invalid or expired access code'); + }); + + it('it should return 404 when the client subscription has expired', async function () { + await AccessClient.updateOne( + { slug: 'public-01' }, + { $set: { subscriptionEnd: new Date(Date.now() - 24 * 60 * 60 * 1000) } } + ); + + const res = await request(server) + .get('/access/public-01/PUBLIC01') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Invalid or expired access code'); + }); + + it('it should return 404 when the client is inactive', async function () { + await AccessClient.updateOne( + { slug: 'public-01' }, + { $set: { isActive: false } } + ); + + const res = await request(server) + .get('/access/public-01/PUBLIC01') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(404); + + res.body.should.have.property('message').eql('Invalid or expired access code'); + }); + }); +}); diff --git a/test/controllers/board.js b/test/controllers/board.js index d480be56..c80d4747 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -278,6 +278,143 @@ describe('Board API calls', function () { }); }); + describe('accessGateCode field behavior', function () { + it('should normalize accessGateCode to uppercase when creating a board', async function () { + const boardWithAccessCode = { + ...helper.boardData, + accessGateCode: 'cafe01' + }; + const res = await request(server) + .post('/board') + .send(boardWithAccessCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('CAFE01'); + }); + + it('should trim whitespace from accessGateCode', async function () { + const boardWithAccessCode = { + ...helper.boardData, + accessGateCode: ' test01 ' + }; + const res = await request(server) + .post('/board') + .send(boardWithAccessCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('TEST01'); + }); + + it('should default accessGateCode to null when not provided', async function () { + const boardWithoutAccessCode = { ...helper.boardData }; + delete boardWithoutAccessCode.accessGateCode; + + const res = await request(server) + .post('/board') + .send(boardWithoutAccessCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('accessGateCode'); + should.equal(res.body.accessGateCode, null); + }); + + it('should update accessGateCode on an existing board', async function () { + const boardId = await helper.createMochaBoard(server, user.token); + + const updateData = { + ...helper.boardData, + accessGateCode: 'updated01' + }; + const res = await request(server) + .put('/board/' + boardId) + .send(updateData) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('UPDATED01'); + }); + + it('should return accessGateCode in GET response when set', async function () { + const boardWithAccessCode = { + ...helper.boardData, + accessGateCode: 'gettest01' + }; + const createRes = await request(server) + .post('/board') + .send(boardWithAccessCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + const boardId = createRes.body.id; + + const getRes = await request(server) + .get('/board/' + boardId) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + getRes.body.should.have.property('accessGateCode'); + getRes.body.accessGateCode.should.equal('GETTEST01'); + }); + + it('should exclude boards with accessGate from public boards listing', async function () { + // Create a public board without accessGate + const publicBoard = { + ...helper.boardData, + name: 'Public Board Without Code', + isPublic: true + }; + await request(server) + .post('/board') + .send(publicBoard) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect(200); + + // Create a public board with accessGateCode + const publicBoardWithCode = { + ...helper.boardData, + name: 'Public Board With Code', + isPublic: true, + accessGateCode: 'public01' + }; + await request(server) + .post('/board') + .send(publicBoardWithCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/json') + .expect(200); + + // Get public boards + const res = await request(server) + .get('/board/public') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + // Verify that boards with accessGateCode are not in the results + const boardsWithCode = res.body.data.filter(b => b.name === 'Public Board With Code'); + boardsWithCode.length.should.equal(0); + }); + }); + describe('GET /board/byemail/:email', function() { it("only allows an admin to get another user's boards", async function() { const adminEmail = helper.generateEmail(); diff --git a/test/helper.js b/test/helper.js index a750532b..ec55514c 100644 --- a/test/helper.js +++ b/test/helper.js @@ -397,6 +397,7 @@ async function deleteMochaUsers() { * * @property {string} token * @property {string} userId + * @property {string} email */ /** @@ -434,7 +435,7 @@ async function prepareUser(server, overrides = {}) { const token = login.body.authToken; - return { token, userId }; + return { token, userId, email: data.email }; } /** @@ -474,15 +475,17 @@ async function createCommunicator(server, userToken) { * @param {Express} server * * @param {string} token - * user data. + * + * @param {string} [email] - Optional email to set as the board owner, should match the authenticated user's email. * * @returns {Promise} */ -async function createMochaBoard(server, token) { +async function createMochaBoard(server, token, email) { + const payload = email ? { ...boardData, email } : boardData; const res = await request(server) .post('/board') - .send(boardData) + .send(payload) .set('Authorization', `Bearer ${token}`); return res.body.id; } diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md new file mode 100644 index 00000000..0515bae6 --- /dev/null +++ b/test/postman/CboardAccess.README.md @@ -0,0 +1,227 @@ +# Cboard Access Postman Collection + +This Postman collection provides comprehensive testing and administration capabilities for the Cboard Access feature. + +## Overview + +Cboard Access allows businesses to offer AAC boards in their locations via QR codes or access codes. This collection tests both public endpoints (used by end users) and admin endpoints (for managing clients). + +## Collection Structure + +### 1. Setup +- **Login to Get Auth Token**: Obtain authentication token for admin endpoints + +### 2. Admin Endpoints (Require Authentication) +- **Create Access Client**: Create a new Cboard Access client and its first access gate +- **List All Clients**: View all registered clients with statistics +- **Update Client**: Modify client information +- **Deactivate Client**: Disable a client by setting `isActive` to false +- **View Statistics**: Get detailed stats for a specific client +- **Update Access Gate**: Re-run board discovery for an access gate + +### 3. Public Endpoints (No Authentication) +- **Test Public Client Listing**: List active clients for app display +- **Test Public Board Access**: Access boards via access code + +## Setup Instructions + +### Prerequisites + +**IMPORTANT: You need an admin user to test admin endpoints.** + +To create an admin user, update your user's role in MongoDB: +```javascript +// In MongoDB shell or Compass +db.users.updateOne( + { email: "your-email@example.com" }, + { $set: { role: "admin" } } +) +``` + +### 1. Import the Collection + +1. Open Postman +2. Click **Import** button +3. Select `CboardAccess.collection.json` +4. The collection will be imported with default variables + +### 2. Configure Variables + +The collection includes these variables (can be edited in collection variables or create an environment): + +| Variable | Default Value | Description | +|----------|---------------|-------------| +| `url` | `http://localhost:10010` | Base API URL | +| `token` | (set automatically) | Authentication token for admin endpoints (saved to globals on login) | +| `client_slug` | `test-coffee-shop` | Client slug for admin endpoints (set automatically on create) | +| `access_code` | `TEST01` | Access gate code for public board access (set automatically on create) | +| `root_board_id` | (empty) | Board ID to use as root board | + +## Usage Workflow + +### First Time Setup + +1. **Get a Board ID** + - Create a board in Cboard or use an existing one + - Copy the board ID + - Set it in the `root_board_id` variable + +2. **Authenticate** + - Update credentials in "Login to Get Auth Token" request + - Run the request + - The `token` variable will be automatically set from the response + +### Creating a Client + +1. Run **Create Access Client** with your desired configuration: + ```json + { + "slug": "coffee-shop-downtown", + "clientName": "Coffee Shop Downtown", + "clientEmail": "manager@coffee.com", + "clientPhone": "+1234567890", + "brandColor": "#8B4513", + "rootBoardId": "{{root_board_id}}", + "accessGateCode": "CAFE01", + "subscriptionStart": "2026-04-01T00:00:00.000Z", + "subscriptionEnd": "2027-04-01T00:00:00.000Z" + } + ``` + +2. The response includes the created client (`slug`, `contact.name`, etc.) and the `accessGate` with `code` and `linkedBoardIds` +3. `client_slug` and `access_code` variables are automatically saved for subsequent requests + +### Testing the Client + +1. **Admin View**: Run "List All Clients" to see all clients with statistics +2. **Public View**: Run "Test Public Client Listing" to see how it appears in the app +3. **Access Boards**: Run "Test Public Board Access" to retrieve the boards +4. **View Stats**: Run "View Statistics" to see detailed analytics + +### Updating or Deactivating + +- Use **Update Client** to modify properties (uses `{{client_slug}}`) +- Use **Deactivate Client** to disable access without deleting + +### Re-discovering Boards + +Use **Update Access Gate** to re-run board discovery after the board structure changes. Optionally pass a new `rootBoardId` in the body to change the root. + +## Request Details + +### Create Access Client + +**Required Fields:** +- `slug`: Unique client identifier (lowercase, URL-safe) +- `clientName`: Display name +- `rootBoardId`: ID of the main board +- `accessGateCode`: Unique code for the access gate (uppercase alphanumeric) +- `subscriptionStart`: Start date (ISO 8601) +- `subscriptionEnd`: End date (ISO 8601) + +**Optional Fields:** +- `clientEmail`: Email of the client +- `clientPhone`: Phone number of the client +- `brandColor`: Hex color code + +Board discovery is automatic — all boards reachable from `rootBoardId` via tile navigation are linked to the access gate. + +### Update Client + +**Updatable Fields:** +- `clientName` +- `clientEmail` +- `clientPhone` +- `brandColor` +- `isActive` (boolean) +- `subscriptionStart` +- `subscriptionEnd` + +### Public Endpoints + +These endpoints don't require authentication and simulate real user access: + +- **GET /access/clients/all**: Returns only active clients with valid subscriptions +- **GET /access/:clientSlug/:gateCode**: Returns all boards for the access code and increments access counter + +## Test Scripts + +Each request includes automated tests: + +- **Status code validation**: Ensures correct HTTP responses +- **Response structure validation**: Checks for required fields +- **Variable auto-setting**: Saves tokens, slugs, and access codes for subsequent requests + +## Common Scenarios + +### Scenario 1: New Client Onboarding + +1. Login to get auth token +2. Create access client (saves `client_slug` and `access_code` automatically) +3. Verify in "List All Clients" +4. Test public access via "Test Public Board Access" + +### Scenario 2: Client Expiration + +1. Create client with near expiration date +2. View statistics to see `daysUntilExpiry` +3. Update subscription dates to extend +4. Verify expiration status updated + +### Scenario 3: Temporary Deactivation + +1. Deactivate client +2. Verify it doesn't appear in public listing +3. Update to reactivate +4. Verify it reappears in public listing + +### Scenario 4: Board Structure Changed + +1. Update boards (add/remove tile navigation) +2. Run "Update Access Gate" to re-discover linked boards +3. Verify `linkedBoardIds` count updated + +## Troubleshooting + +### token Not Being Set After Login +- Check the Postman Console (View → Show Postman Console) for error messages +- Verify your email and password are correct in the login request body +- Make sure the API server is running (`npm run dev` in cboard-api) +- Check that the response contains `authToken` field + +### 403 Forbidden on Admin Endpoints +- **You need an admin user!** By default, all users have `role: "user"` +- Update your user to admin role in MongoDB: + ```javascript + db.users.updateOne( + { email: "your-email@example.com" }, + { $set: { role: "admin" } } + ) + ``` +- After updating, log in again to get a new token with admin privileges + +### "Invalid access code" Error +- Ensure the client is active (`isActive: true`) +- Check subscription dates are valid (start ≤ now ≤ end) + +### Authentication Errors +- Ensure `token` is set (run login request) +- Check token hasn't expired +- Verify user has admin privileges (role: "admin") + +### Board Not Found +- Ensure `root_board_id` exists in the database +- Check board hasn't been deleted +- Verify board belongs to the authenticated user + +## Related Documentation + +- [GitHub Issue #446](https://github.com/cboard-org/cboard-api/issues/446) +- [Parent Issue #439](https://github.com/cboard-org/cboard-api/issues/439) + +## Notes + +- All admin endpoints require authentication via Bearer token +- Access count is automatically incremented on each board access +- Subscription dates are checked against current server time +- Board discovery traverses `tile.loadBoard` references recursively from the root board diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json new file mode 100644 index 00000000..8a029183 --- /dev/null +++ b/test/postman/CboardAccess.collection.json @@ -0,0 +1,554 @@ +{ + "info": { + "_postman_id": "a1b2c3d4-5e6f-7g8h-9i0j-1k2l3m4n5o6p", + "name": "Cboard Access Admin", + "description": "Collection for testing and administering Cboard Access clients. Includes both public endpoints (client listing, board access) and admin endpoints (CRUD operations, statistics).", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Admin", + "description": "Administrative endpoints for managing Cboard Access clients. Requires authentication.", + "item": [ + { + "name": "Create Access Client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 201\", function () {", + " pm.response.to.have.status(201);", + "});", + "", + "pm.test(\"Response contains client data\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('slug');", + " pm.expect(jsonData).to.have.property('contact');", + " pm.expect(jsonData.contact).to.have.property('name');", + "});", + "", + "pm.test(\"Response contains access gate with auto-discovered boards\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('accessGate');", + " pm.expect(jsonData.accessGate).to.have.property('code');", + " pm.expect(jsonData.accessGate).to.have.property('linkedBoardIds');", + " pm.expect(jsonData.accessGate.linkedBoardIds.length).to.be.at.least(1);", + " console.log(\"Auto-discovered \" + jsonData.accessGate.linkedBoardIds.length + \" boards\");", + "});", + "", + "// Save the slug and access gate code for later requests", + "if (pm.response.code === 201) {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set(\"client_slug\", jsonData.slug);", + " pm.collectionVariables.set(\"access_code\", jsonData.accessGate.code);", + " console.log(\"Client slug saved: \" + jsonData.slug);", + " console.log(\"Access code saved: \" + jsonData.accessGate.code);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"slug\": \"test-coffee-shop\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientEmail\": \"contact@testcoffee.com\",\n \"clientPhone\": \"+1234567890\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"accessGateCode\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" + }, + "url": { + "raw": "{{url}}/admin/access/clients", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "clients" + ] + }, + "description": "Creates a new Cboard Access client with the specified configuration. Requires admin authentication." + }, + "response": [] + }, + { + "name": "List All Clients", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains client list\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('total');", + " pm.expect(jsonData).to.have.property('data');", + " pm.expect(jsonData.data).to.be.an('array');", + "});", + "", + "pm.test(\"Clients have required fields\", function () {", + " var jsonData = pm.response.json();", + " if (jsonData.data.length > 0) {", + " var client = jsonData.data[0];", + " pm.expect(client).to.have.property('slug');", + " pm.expect(client).to.have.property('contact');", + " pm.expect(client.contact).to.have.property('name');", + " pm.expect(client).to.have.property('boardCount');", + " pm.expect(client).to.have.property('isExpired');", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{url}}/admin/access/clients", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "clients" + ] + }, + "description": "Lists all Cboard Access clients with their statistics. Requires admin authentication." + }, + "response": [] + }, + { + "name": "Update Client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Client was updated\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('slug');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientEmail\": \"newemail@testcoffee.com\",\n \"clientPhone\": \"+1234567890\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true\n}" + }, + "url": { + "raw": "{{url}}/admin/access/clients/{{client_slug}}", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "clients", + "{{client_slug}}" + ] + }, + "description": "Updates an existing Cboard Access client. Requires admin authentication." + }, + "response": [] + }, + { + "name": "Deactivate Client", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Client was deactivated\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.isActive).to.be.false;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isActive\": false\n}" + }, + "url": { + "raw": "{{url}}/admin/access/clients/{{client_slug}}", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "clients", + "{{client_slug}}" + ] + }, + "description": "Deactivates a Cboard Access client by setting isActive to false. Requires admin authentication." + }, + "response": [] + }, + { + "name": "View Statistics", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains statistics\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('client');", + " pm.expect(jsonData).to.have.property('stats');", + "});", + "", + "pm.test(\"Stats have required fields\", function () {", + " var stats = pm.response.json().stats;", + " pm.expect(stats).to.have.property('totalAccesses');", + " pm.expect(stats).to.have.property('boardCount');", + " pm.expect(stats).to.have.property('boards');", + " pm.expect(stats).to.have.property('daysUntilExpiry');", + " pm.expect(stats).to.have.property('isExpired');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{url}}/admin/access/clients/{{client_slug}}/stats", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "clients", + "{{client_slug}}", + "stats" + ] + }, + "description": "Retrieves detailed statistics for a specific Cboard Access client. Requires admin authentication." + }, + "response": [] + }, + { + "name": "Update Access Gate", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains access gate data\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('code');", + " pm.expect(jsonData).to.have.property('linkedBoardIds');", + " pm.expect(jsonData).to.have.property('rootBoardId');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{}" + }, + "url": { + "raw": "{{url}}/admin/access/gates/{{access_code}}", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access", + "gates", + "{{access_code}}" + ] + }, + "description": "Re-runs board discovery for an access gate. Optionally pass a new rootBoardId to change the root and re-discover from there. Useful when the board structure has changed." + }, + "response": [] + } + ] + }, + { + "name": "Public", + "description": "Public endpoints accessible without authentication for end-user functionality.", + "item": [ + { + "name": "Test Public Client Listing", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains active clients\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('total');", + " pm.expect(jsonData).to.have.property('data');", + " pm.expect(jsonData.data).to.be.an('array');", + "});", + "", + "pm.test(\"Clients have required fields\", function () {", + " var clients = pm.response.json().data;", + " clients.forEach(function(client) {", + " pm.expect(client).to.have.property('slug');", + " pm.expect(client).to.have.property('clientName');", + " pm.expect(client).to.have.property('brandColor');", + " pm.expect(client).to.have.property('accessGates');", + " pm.expect(client.accessGates).to.be.an('array');", + " });", + "});", + "", + "// Save first access gate code for testing board access", + "var data = pm.response.json().data;", + "if (data.length > 0 && data[0].accessGates.length > 0) {", + " var code = data[0].accessGates[0].code;", + " pm.collectionVariables.set(\"access_code\", code);", + " console.log(\"Access code saved: \" + code);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/access/clients/all", + "host": [ + "{{url}}" + ], + "path": [ + "access", + "clients", + "all" + ] + }, + "description": "Public endpoint that lists all active Cboard Access clients for display in the app. No authentication required." + }, + "response": [] + }, + { + "name": "Test Public Board Access", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains client and boards data\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('client');", + " pm.expect(jsonData).to.have.property('boards');", + " pm.expect(jsonData).to.have.property('rootBoardId');", + "});", + "", + "pm.test(\"Client data is complete\", function () {", + " var client = pm.response.json().client;", + " pm.expect(client).to.have.property('code');", + " pm.expect(client).to.have.property('name');", + " pm.expect(client).to.have.property('color');", + "});", + "", + "pm.test(\"Boards array is present\", function () {", + " var boards = pm.response.json().boards;", + " pm.expect(boards).to.be.an('array');", + " pm.expect(boards.length).to.be.greaterThan(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{url}}/access/{{client_slug}}/{{access_code}}", + "host": [ + "{{url}}" + ], + "path": [ + "access", + "{{client_slug}}", + "{{access_code}}" + ] + }, + "description": "Public endpoint that retrieves all boards for a slug + access code pair. Used when scanning QR codes or entering access codes. No authentication required." + }, + "response": [] + } + ] + }, + { + "name": "Setup", + "description": "Helper requests for initial setup and authentication.", + "item": [ + { + "name": "Login to Get Auth Token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.globals.set(\"token\", pm.response.json().authToken);", + "console.log(\"Token saved: \" + pm.globals.get(\"token\").substring(0, 30) + \"...\");" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@cboard.io\",\n \"password\": \"your-password\"\n}" + }, + "url": { + "raw": "{{url}}/user/login/", + "host": [ + "{{url}}" + ], + "path": [ + "user", + "login", + "" + ] + }, + "description": "Login to obtain an authentication token. Update the email and password with valid admin credentials." + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "url", + "value": "http://localhost:10010", + "type": "string" + }, + { + "key": "client_slug", + "value": "test-coffee-shop", + "type": "string" + }, + { + "key": "access_code", + "value": "TEST01", + "type": "string" + }, + { + "key": "root_board_id", + "value": "", + "type": "string" + } + ] +}