From 501cfb6491b6eccb7bf3895af06124ecad7db1ed Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 31 Mar 2026 19:57:34 -0300 Subject: [PATCH 001/113] Add accessCode field to Board model schema --- api/models/Board.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/models/Board.js b/api/models/Board.js index b007470b..bece4246 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: {}, + accessCode: { + type: String, + default: null, + trim: true, + uppercase: true + } }; const BOARD_SCHEMA_OPTIONS = { @@ -81,6 +87,9 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); +// Sparse index allows multiple null values while indexing non-null accessCodes +boardSchema.index({ accessCode: 1 }, { sparse: true }); + const validatePresenceOf = value => value && value.length; /** From 2ea1eaeb00db6b3af07dc57617b87b65e64b972b Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 31 Mar 2026 20:43:23 -0300 Subject: [PATCH 002/113] Remove default value for accessCode and update index comment for clarity --- api/models/Board.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index bece4246..94d3bf73 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -65,7 +65,6 @@ const BOARD_SCHEMA_DEFINITION = { grid: {}, accessCode: { type: String, - default: null, trim: true, uppercase: true } @@ -87,7 +86,7 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse index allows multiple null values while indexing non-null accessCodes +// Sparse index skips documents where accessCode is missing, optimizing queries for Access boards boardSchema.index({ accessCode: 1 }, { sparse: true }); const validatePresenceOf = value => value && value.length; From 1ff631a61437b6e3755a761cd30ce09454c1ea07 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 31 Mar 2026 20:43:34 -0300 Subject: [PATCH 003/113] Add tests for accessCode field behavior in Board API --- test/controllers/board.js | 95 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/test/controllers/board.js b/test/controllers/board.js index 62e39568..d4974885 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,6 +252,101 @@ describe('Board API calls', function () { }); }); + describe('accessCode field behavior', function() { + it('should normalize accessCode to uppercase when creating a board', async function() { + const boardWithAccessCode = { + ...helper.boardData, + accessCode: '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('accessCode'); + res.body.accessCode.should.equal('CAFE01'); + }); + + it('should trim whitespace from accessCode', async function() { + const boardWithAccessCode = { + ...helper.boardData, + accessCode: ' 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('accessCode'); + res.body.accessCode.should.equal('TEST01'); + }); + + it('should not include accessCode when not provided', async function() { + const boardWithoutAccessCode = { ...helper.boardData }; + delete boardWithoutAccessCode.accessCode; + + 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.not.have.property('accessCode'); + }); + + it('should update accessCode on an existing board', async function() { + const boardId = await helper.createMochaBoard(server, user.token); + + const updateData = { + ...helper.boardData, + accessCode: '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('accessCode'); + res.body.accessCode.should.equal('UPDATED01'); + }); + + it('should return accessCode in GET response when set', async function() { + const boardWithAccessCode = { + ...helper.boardData, + accessCode: 'gettest01' + }; + const createRes = await request(server) + .post('/board') + .send(boardWithAccessCode) + .set('Authorization', `Bearer ${user.token}`) + .set('Accept', 'application/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('accessCode'); + getRes.body.accessCode.should.equal('GETTEST01'); + }); + }); + describe('GET /board/byemail/:email', function() { it("only allows an admin to get another user's boards", async function() { const adminEmail = helper.generateEmail(); From 055059a12d9a9794376f4844a64cd18de71e088f Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 31 Mar 2026 21:12:24 -0300 Subject: [PATCH 004/113] Refactor accessCode field tests for consistency and clarity --- test/controllers/board.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index d4974885..c5841a5f 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,8 +252,8 @@ describe('Board API calls', function () { }); }); - describe('accessCode field behavior', function() { - it('should normalize accessCode to uppercase when creating a board', async function() { + describe('accessCode field behavior', function () { + it('should normalize accessCode to uppercase when creating a board', async function () { const boardWithAccessCode = { ...helper.boardData, accessCode: 'cafe01' @@ -270,7 +270,7 @@ describe('Board API calls', function () { res.body.accessCode.should.equal('CAFE01'); }); - it('should trim whitespace from accessCode', async function() { + it('should trim whitespace from accessCode', async function () { const boardWithAccessCode = { ...helper.boardData, accessCode: ' test01 ' @@ -287,7 +287,7 @@ describe('Board API calls', function () { res.body.accessCode.should.equal('TEST01'); }); - it('should not include accessCode when not provided', async function() { + it('should not include accessCode when not provided', async function () { const boardWithoutAccessCode = { ...helper.boardData }; delete boardWithoutAccessCode.accessCode; @@ -302,7 +302,7 @@ describe('Board API calls', function () { res.body.should.not.have.property('accessCode'); }); - it('should update accessCode on an existing board', async function() { + it('should update accessCode on an existing board', async function () { const boardId = await helper.createMochaBoard(server, user.token); const updateData = { @@ -321,7 +321,7 @@ describe('Board API calls', function () { res.body.accessCode.should.equal('UPDATED01'); }); - it('should return accessCode in GET response when set', async function() { + it('should return accessCode in GET response when set', async function () { const boardWithAccessCode = { ...helper.boardData, accessCode: 'gettest01' @@ -331,6 +331,7 @@ describe('Board API calls', function () { .send(boardWithAccessCode) .set('Authorization', `Bearer ${user.token}`) .set('Accept', 'application/json') + .expect('Content-Type', /json/) .expect(200); const boardId = createRes.body.id; From eb7ce23396c965f1103e023d1827e6771bad4f7e Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 31 Mar 2026 21:27:17 -0300 Subject: [PATCH 005/113] Add AccessClient model schema for managing access clients --- api/models/AccessClient.js | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 api/models/AccessClient.js diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js new file mode 100644 index 00000000..1921b17d --- /dev/null +++ b/api/models/AccessClient.js @@ -0,0 +1,86 @@ +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ACCESS_CLIENT_SCHEMA_DEFINITION = { + code: { + type: String, + unique: true, + required: true, + trim: true, + uppercase: true + }, + clientName: { + type: String, + required: true, + trim: true + }, + clientContact: { + type: String, + trim: true + }, + brandColor: { + type: String + }, + rootBoardId: { + type: Schema.Types.ObjectId, + ref: 'Board', + required: true + }, + isActive: { + type: Boolean, + default: true + }, + isListedInApp: { + type: Boolean, + default: true + }, + subscriptionStart: { + type: Date, + required: true + }, + subscriptionEnd: { + type: Date, + required: true + }, + accessCount: { + type: Number, + default: 0 + }, + lastAccessAt: { + type: Date + }, + 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 +); + +accessClientSchema.index({ code: 1 }, { unique: true }); +accessClientSchema.index({ isActive: 1, isListedInApp: 1 }); + +const AccessClient = mongoose.model('AccessClient', accessClientSchema); + +module.exports = AccessClient; From 25c2087ff4d51108975d4696ae758606b76878f0 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 12:00:48 -0300 Subject: [PATCH 006/113] Remove redundant unique index on accessClientSchema for code field --- api/models/AccessClient.js | 1 - 1 file changed, 1 deletion(-) diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js index 1921b17d..32ac1fad 100644 --- a/api/models/AccessClient.js +++ b/api/models/AccessClient.js @@ -78,7 +78,6 @@ const accessClientSchema = new Schema( ACCESS_CLIENT_SCHEMA_OPTIONS ); -accessClientSchema.index({ code: 1 }, { unique: true }); accessClientSchema.index({ isActive: 1, isListedInApp: 1 }); const AccessClient = mongoose.model('AccessClient', accessClientSchema); From 234c9ecb131f499c12d024eaeb6538da016331b6 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 12:08:16 -0300 Subject: [PATCH 007/113] Add access controller with public endpoints for client and board retrieval --- api/controllers/access.js | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 api/controllers/access.js diff --git a/api/controllers/access.js b/api/controllers/access.js new file mode 100644 index 00000000..d2887b42 --- /dev/null +++ b/api/controllers/access.js @@ -0,0 +1,122 @@ +'use strict'; + +const AccessClient = require('../models/AccessClient'); +const Board = require('../models/Board'); + +module.exports = { + getClients: getClients, + getAccessBoard: getAccessBoard +}; + +/** + * GET /access/clients + * Lists active companies for Cboard Access listing in the app. + * Returns clients where isActive=true, isListedInApp=true, and subscription is valid. + * Populates rootBoard basic info (name, caption, tiles count). + * Sorted by clientName. + */ +async function getClients(req, res) { + try { + const now = new Date(); + const clients = await AccessClient.find({ + isActive: true, + isListedInApp: true, + subscriptionStart: { $lte: now }, + subscriptionEnd: { $gte: now } + }) + .populate('rootBoardId', 'name caption tiles') + .select('code clientName brandColor rootBoardId') + .sort({ clientName: 1 }); + + const result = clients.map(client => ({ + code: client.code, + clientName: client.clientName, + brandColor: client.brandColor, + rootBoard: client.rootBoardId + ? { + id: client.rootBoardId._id, + name: client.rootBoardId.name, + caption: client.rootBoardId.caption, + tilesCount: client.rootBoardId.tiles?.length || 0 + } + : null + })); + + 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/:code + * Gets ALL boards for an access code in a single request. + * This enables instant frontend navigation without additional requests. + * Validates code exists and subscription is active. + * Returns client info (code, name, color), all boards array, and rootBoardId. + * Increments accessCount and updates lastAccessAt. + */ +async function getAccessBoard(req, res) { + const code = req.swagger.params.code.value.toUpperCase(); + + try { + const now = new Date(); + const client = await AccessClient.findOne({ + code: code, + isActive: true, + subscriptionStart: { $lte: now }, + subscriptionEnd: { $gte: now } + }); + + if (!client) { + return res.status(404).json({ + message: 'Invalid or expired access code' + }); + } + + // Get ALL boards with this accessCode + const boards = await Board.find({ accessCode: code }); + + if (!boards || boards.length === 0) { + return res.status(404).json({ + message: 'No boards available for this code' + }); + } + + // Verify the rootBoard exists + const rootBoard = boards.find( + b => b._id.toString() === client.rootBoardId.toString() + ); + if (!rootBoard) { + return res.status(404).json({ + message: 'Root board not found' + }); + } + + // Track access analytics + client.accessCount += 1; + client.lastAccessAt = new Date(); + await client.save(); + + return res.status(200).json({ + client: { + code: client.code, + name: client.clientName, + color: client.brandColor + }, + boards: boards.map(b => b.toJSON()), + rootBoardId: client.rootBoardId.toString() + }); + } catch (err) { + return res.status(500).json({ + message: 'Error getting boards', + error: err.message + }); + } +} From d4c82d404fc701191084b53029a1fa47aaec9463 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 12:25:21 -0300 Subject: [PATCH 008/113] Enhance getAccessBoard to exclude PII fields and improve access analytics tracking --- api/controllers/access.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index d2887b42..488a4373 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -80,8 +80,10 @@ async function getAccessBoard(req, res) { }); } - // Get ALL boards with this accessCode - const boards = await Board.find({ accessCode: code }); + // Get ALL boards with this accessCode (exclude PII fields for public endpoint) + const boards = await Board.find({ accessCode: code }).select( + '-email -author' + ); if (!boards || boards.length === 0) { return res.status(404).json({ @@ -99,10 +101,14 @@ async function getAccessBoard(req, res) { }); } - // Track access analytics - client.accessCount += 1; - client.lastAccessAt = new Date(); - await client.save(); + // Track access analytics atomically to avoid lost updates under concurrency + await AccessClient.updateOne( + { _id: client._id }, + { + $inc: { accessCount: 1 }, + $set: { lastAccessAt: new Date() } + } + ); return res.status(200).json({ client: { From bfefe775f7c13011e7e81806461f0257b051e13e Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 12:59:55 -0300 Subject: [PATCH 009/113] Add admin endpoints for managing Access clients and their statistics --- api/controllers/access.js | 195 +++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 1 deletion(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 488a4373..746314ea 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -5,7 +5,11 @@ const Board = require('../models/Board'); module.exports = { getClients: getClients, - getAccessBoard: getAccessBoard + getAccessBoard: getAccessBoard, + createAccessClient: createAccessClient, + listAccessClients: listAccessClients, + updateAccessClient: updateAccessClient, + getAccessClientStats: getAccessClientStats }; /** @@ -126,3 +130,192 @@ async function getAccessBoard(req, res) { }); } } + +// ============================================ +// ADMIN ENDPOINTS +// ============================================ + +/** + * POST /api/admin/access-clients + * Creates a new Access client. + * Requires admin authentication (enforced by Swagger middleware). + * Creates AccessClient and updates boards with accessCode. + * Validates rootBoardId exists. + */ +async function createAccessClient(req, res) { + const { + code, + clientName, + clientContact, + brandColor, + rootBoardId, + subscriptionStart, + subscriptionEnd, + boardIds // Array of board IDs to associate + } = 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' }); + } + + // Create the client + const client = new AccessClient({ + code: code.toUpperCase(), + clientName, + clientContact, + brandColor, + rootBoardId, + subscriptionStart: new Date(subscriptionStart), + subscriptionEnd: new Date(subscriptionEnd), + createdBy: req.user.id + }); + + await client.save(); + + // Mark the boards with the accessCode + const allBoardIds = boardIds || [rootBoardId]; + await Board.updateMany( + { _id: { $in: allBoardIds } }, + { $set: { accessCode: code.toUpperCase() } } + ); + + return res.status(201).json(client.toJSON()); + } catch (err) { + if (err.code === 11000) { + return res.status(409).json({ message: 'Code already exists' }); + } + return res.status(500).json({ + message: 'Error creating client', + error: err.message + }); + } +} + +/** + * GET /api/admin/access-clients + * Lists all Access clients with stats. + * Returns all clients with board counts, expiry status. + * Populates rootBoard and createdBy. + */ +async function listAccessClients(req, res) { + try { + const clients = await AccessClient.find() + .populate('rootBoardId', 'name') + .populate('createdBy', 'name email') + .sort({ createdAt: -1 }); + + // For each client, count associated boards + const result = await Promise.all( + clients.map(async client => { + const boardCount = await Board.countDocuments({ + accessCode: client.code + }); + + return { + ...client.toJSON(), + boardCount, + isExpired: client.subscriptionEnd < new Date(), + daysUntilExpiry: Math.ceil( + (client.subscriptionEnd - new Date()) / (1000 * 60 * 60 * 24) + ) + }; + }) + ); + + 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 /api/admin/access-clients/:code + * Updates an Access client. + * Allowed fields: isActive, isListedInApp, subscription dates, branding. + */ +async function updateAccessClient(req, res) { + const code = req.swagger.params.code.value.toUpperCase(); + const updates = req.body; + + try { + const client = await AccessClient.findOne({ code }); + + if (!client) { + return res.status(404).json({ message: 'Client not found' }); + } + + // Updatable fields + if (updates.isActive !== undefined) client.isActive = updates.isActive; + if (updates.isListedInApp !== undefined) + client.isListedInApp = updates.isListedInApp; + if (updates.subscriptionStart) + client.subscriptionStart = new Date(updates.subscriptionStart); + if (updates.subscriptionEnd) + client.subscriptionEnd = new Date(updates.subscriptionEnd); + if (updates.clientName) client.clientName = updates.clientName; + if (updates.clientContact) client.clientContact = updates.clientContact; + 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 /api/admin/access-clients/:code/stats + * Gets detailed statistics for an Access client. + * Returns client info, access counts, board list with tile counts. + */ +async function getAccessClientStats(req, res) { + const code = req.swagger.params.code.value.toUpperCase(); + + try { + const client = await AccessClient.findOne({ code }) + .populate('rootBoardId', 'name tiles') + .populate('createdBy', 'name email'); + + if (!client) { + return res.status(404).json({ message: 'Client not found' }); + } + + const boards = await Board.find({ accessCode: code }, { name: 1, tiles: 1 }); + + return res.status(200).json({ + client: client.toJSON(), + stats: { + totalAccesses: client.accessCount, + lastAccessAt: client.lastAccessAt, + boardCount: boards.length, + boards: boards.map(b => ({ + id: b._id, + name: b.name, + tilesCount: b.tiles?.length || 0 + })), + daysUntilExpiry: Math.ceil( + (client.subscriptionEnd - new Date()) / (1000 * 60 * 60 * 24) + ), + isExpired: client.subscriptionEnd < new Date() + } + }); + } catch (err) { + return res.status(500).json({ + message: 'Error getting client statistics', + error: err.message + }); + } +} From db07acecd4debe729e4db70b5556ee6bc34182a0 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:16:24 -0300 Subject: [PATCH 010/113] Refactor createAccessClient and listAccessClients to ensure rootBoardId inclusion and optimize board count retrieval --- api/controllers/access.js | 59 ++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 746314ea..c5f1aae5 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -176,7 +176,10 @@ async function createAccessClient(req, res) { await client.save(); // Mark the boards with the accessCode - const allBoardIds = boardIds || [rootBoardId]; + // Ensure rootBoardId is always included even if boardIds is empty array + const allBoardIds = boardIds && boardIds.length > 0 + ? [...new Set([rootBoardId.toString(), ...boardIds.map(id => id.toString())])] + : [rootBoardId]; await Board.updateMany( { _id: { $in: allBoardIds } }, { $set: { accessCode: code.toUpperCase() } } @@ -207,23 +210,32 @@ async function listAccessClients(req, res) { .populate('createdBy', 'name email') .sort({ createdAt: -1 }); - // For each client, count associated boards - const result = await Promise.all( - clients.map(async client => { - const boardCount = await Board.countDocuments({ - accessCode: client.code - }); - - return { - ...client.toJSON(), - boardCount, - isExpired: client.subscriptionEnd < new Date(), - daysUntilExpiry: Math.ceil( - (client.subscriptionEnd - new Date()) / (1000 * 60 * 60 * 24) - ) - }; - }) - ); + // Precompute board counts for all clients in a single query to avoid N+1 + const accessCodes = clients.map(client => client.code); + const boardCounts = await Board.aggregate([ + { $match: { accessCode: { $in: accessCodes } } }, + { $group: { _id: '$accessCode', count: { $sum: 1 } } } + ]); + + const boardCountMap = boardCounts.reduce((map, entry) => { + map[entry._id] = entry.count; + return map; + }, {}); + + const now = new Date(); + const result = clients.map(client => { + const boardCount = boardCountMap[client.code] || 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, @@ -295,6 +307,11 @@ async function getAccessClientStats(req, res) { const boards = await Board.find({ accessCode: code }, { name: 1, tiles: 1 }); + const now = new Date(); + const daysUntilExpiry = Math.ceil( + (client.subscriptionEnd - now) / (1000 * 60 * 60 * 24) + ); + return res.status(200).json({ client: client.toJSON(), stats: { @@ -306,10 +323,8 @@ async function getAccessClientStats(req, res) { name: b.name, tilesCount: b.tiles?.length || 0 })), - daysUntilExpiry: Math.ceil( - (client.subscriptionEnd - new Date()) / (1000 * 60 * 60 * 24) - ), - isExpired: client.subscriptionEnd < new Date() + daysUntilExpiry: Math.max(0, daysUntilExpiry), + isExpired: client.subscriptionEnd < now } }); } catch (err) { From 7002c62fc16adb34f04b3db40992012e0cee0cb2 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:20:59 -0300 Subject: [PATCH 011/113] Add tests for admin access client endpoints including creation, listing, updating, and statistics retrieval --- test/controllers/access.js | 425 +++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 test/controllers/access.js diff --git a/test/controllers/access.js b/test/controllers/access.js new file mode 100644 index 00000000..a5b0f0da --- /dev/null +++ b/test/controllers/access.js @@ -0,0 +1,425 @@ +//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 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 () { + // 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); + testBoardId2 = await helper.createMochaBoard(server, adminUser.token); + }); + + after(async function () { + helper.prepareNodemailerMock(true); //disable mockery + await helper.deleteMochaUsers(); + await Board.deleteMany({ author: 'cboard mocha test' }); + await AccessClient.deleteMany({ clientName: /mocha test/i }); + }); + + describe('POST /admin/access-clients', function () { + it('it should CREATE an access client with rootBoard only', async function () { + const clientData = { + code: 'TEST01', + clientName: 'Test Client mocha test', + clientContact: 'test@example.com', + brandColor: '#FF5733', + rootBoardId: testBoardId, + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now + }; + + 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('code').eql('TEST01'); + res.body.should.have.property('clientName').eql('Test Client mocha test'); + res.body.should.have.property('brandColor').eql('#FF5733'); + res.body.should.have.property('createdBy'); + + // Verify board was updated with accessCode + const board = await Board.findById(testBoardId); + board.accessCode.should.eql('TEST01'); + }); + + it('it should CREATE an access client with multiple boards', async function () { + const clientData = { + code: 'TEST02', + clientName: 'Test Client 2 mocha test', + rootBoardId: testBoardId, + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + boardIds: [testBoardId, testBoardId2], + }; + + 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.have.property('code').eql('TEST02'); + + // Verify both boards were updated + const board1 = await Board.findById(testBoardId); + const board2 = await Board.findById(testBoardId2); + board1.accessCode.should.eql('TEST02'); + board2.accessCode.should.eql('TEST02'); + }); + + it('it should include rootBoard even when boardIds is empty array', async function () { + const clientData = { + code: 'TEST03', + clientName: 'Test Client 3 mocha test', + rootBoardId: testBoardId, + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + boardIds: [], // Empty array + }; + + 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); + + // Verify rootBoard was updated despite empty boardIds + const board = await Board.findById(testBoardId); + board.accessCode.should.eql('TEST03'); + }); + + it('it should NOT CREATE with duplicate code', async function () { + const clientData = { + code: 'DUPLICATE', + clientName: 'Test Client mocha test', + rootBoardId: testBoardId, + 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 duplicate + 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(409); + + res.body.should.have.property('message').eql('Code already exists'); + }); + + it('it should NOT CREATE with non-existent rootBoardId', async function () { + const clientData = { + code: 'TEST04', + clientName: 'Test Client mocha test', + rootBoardId: '507f1f77bcf86cd799439011', // Non-existent ID + 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 = { + code: 'TEST05', + clientName: 'Test Client mocha test', + rootBoardId: testBoardId, + 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({ + code: 'LIST01', + clientName: 'List Test 1 mocha test', + rootBoardId: testBoardId, + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + boardIds: [testBoardId, testBoardId2], + }) + .set('Authorization', `Bearer ${adminUser.token}`); + + await request(server) + .post('/admin/access-clients') + .send({ + code: 'LIST02', + clientName: 'List Test 2 mocha test', + rootBoardId: testBoardId, + 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); + + // Check first client + const client1 = res.body.data.find((c) => c.code === 'LIST01'); + client1.should.have.property('boardCount').eql(2); + 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.code === 'LIST02'); + 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/:code', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access-clients') + .send({ + code: 'UPDATE01', + clientName: 'Update Test mocha test', + rootBoardId: testBoardId, + 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/UPDATE01') + .send(updates) + .set('Authorization', `Bearer ${adminUser.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + res.body.should.have.property('clientName').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/UPDATE01') + .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 code', 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/UPDATE01') + .send({ clientName: 'Test' }) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); + + describe('GET /admin/access-clients/:code/stats', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access-clients') + .send({ + code: 'STATS01', + clientName: 'Stats Test mocha test', + rootBoardId: testBoardId, + subscriptionStart: new Date(), + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + boardIds: [testBoardId, testBoardId2], + }) + .set('Authorization', `Bearer ${adminUser.token}`); + }); + + it('it should GET client statistics', async function () { + const res = await request(server) + .get('/admin/access-clients/STATS01/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('code').eql('STATS01'); + res.body.client.should.have.property('clientName').eql('Stats Test mocha test'); + + // Verify stats + res.body.stats.should.have.property('totalAccesses').eql(0); + res.body.stats.should.have.property('boardCount').eql(2); + res.body.stats.should.have.property('boards').that.is.an('array'); + res.body.stats.boards.length.should.eql(2); + 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({ + code: 'EXPIRED01', + clientName: 'Expired Test mocha test', + rootBoardId: testBoardId, + 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/EXPIRED01/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 code', 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/STATS01/stats') + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(403); + }); + }); +}); From 1633788424fb8ae0b6d8dafab7c44664f14b6173 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:26:43 -0300 Subject: [PATCH 012/113] Enhance board access validation by blocking direct access to boards with access codes --- api/controllers/board.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 12ec7012..66ade313 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -80,7 +80,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, accessCode: null } }, req.query ); @@ -126,6 +126,16 @@ function getBoard(req, res) { message: 'Board does not exist. Board Id: ' + id }); } + + // If the board has accessCode, block direct access + if (boards.accessCode) { + return res.status(403).json({ + message: 'This board requires an access code', + requiresAccessCode: true, + accessCode: boards.accessCode + }); + } + return res.status(200).json(boards.toJSON()); }); } From 1483afc3fd9ce848d8615c7e80e561a3904804cb Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:26:52 -0300 Subject: [PATCH 013/113] Block direct access to boards with access codes and exclude them from public boards listing --- test/controllers/board.js | 49 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index c5841a5f..7a614f8c 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -321,7 +321,7 @@ describe('Board API calls', function () { res.body.accessCode.should.equal('UPDATED01'); }); - it('should return accessCode in GET response when set', async function () { + it('should block direct access to boards with accessCode', async function () { const boardWithAccessCode = { ...helper.boardData, accessCode: 'gettest01' @@ -336,16 +336,61 @@ describe('Board API calls', function () { const boardId = createRes.body.id; + // Direct access should be blocked with 403 const getRes = await request(server) .get('/board/' + boardId) .set('Authorization', `Bearer ${user.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) - .expect(200); + .expect(403); + getRes.body.should.have.property('message'); + getRes.body.message.should.equal('This board requires an access code'); + getRes.body.should.have.property('requiresAccessCode'); + getRes.body.requiresAccessCode.should.equal(true); getRes.body.should.have.property('accessCode'); getRes.body.accessCode.should.equal('GETTEST01'); }); + + it('should exclude boards with accessCode from public boards listing', async function () { + // Create a public board without accessCode + 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 accessCode + const publicBoardWithCode = { + ...helper.boardData, + name: 'Public Board With Code', + isPublic: true, + accessCode: '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 accessCode are not in the results + const boardsWithAccessCode = res.body.data.filter(b => b.accessCode !== null && b.accessCode !== undefined); + boardsWithAccessCode.should.have.lengthOf(0); + }); }); describe('GET /board/byemail/:email', function() { From b5d873695a878b62bdb234b43390901494b320f7 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:43:18 -0300 Subject: [PATCH 014/113] Refactor admin endpoint paths to remove redundant API prefix --- api/controllers/access.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index c5f1aae5..0eec1c65 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -136,7 +136,7 @@ async function getAccessBoard(req, res) { // ============================================ /** - * POST /api/admin/access-clients + * POST /admin/access-clients * Creates a new Access client. * Requires admin authentication (enforced by Swagger middleware). * Creates AccessClient and updates boards with accessCode. @@ -198,7 +198,7 @@ async function createAccessClient(req, res) { } /** - * GET /api/admin/access-clients + * GET /admin/access-clients * Lists all Access clients with stats. * Returns all clients with board counts, expiry status. * Populates rootBoard and createdBy. @@ -250,7 +250,7 @@ async function listAccessClients(req, res) { } /** - * PUT /api/admin/access-clients/:code + * PUT /admin/access-clients/:code * Updates an Access client. * Allowed fields: isActive, isListedInApp, subscription dates, branding. */ @@ -289,7 +289,7 @@ async function updateAccessClient(req, res) { } /** - * GET /api/admin/access-clients/:code/stats + * GET /admin/access-clients/:code/stats * Gets detailed statistics for an Access client. * Returns client info, access counts, board list with tile counts. */ From e31c54c1dfcabf8469229ec7b6c8b6b196eb5f66 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:49:17 -0300 Subject: [PATCH 015/113] Add access routes to Swagger for managing access clients and boards --- api/swagger/swagger.yaml | 417 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 416 insertions(+), 1 deletion(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index cc372ced..e7b5ab8a 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1264,6 +1264,183 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" + /access/clients: + x-swagger-router-controller: access + get: + operationId: getClients + description: Lists active companies for Cboard Access listing in the app. Returns clients where isActive=true, isListedInApp=true, and subscription is valid. + responses: + "200": + description: Success + schema: + $ref: "#/definitions/AccessClientsListResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" + /access/{code}: + x-swagger-router-controller: access + get: + operationId: getAccessBoard + description: Gets ALL boards for an access code in a single request. Validates code exists and subscription is active. Returns client info, all boards array, and rootBoardId. + parameters: + - name: code + type: string + in: path + required: true + description: Access 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. Requires admin authentication. Creates AccessClient and updates boards with accessCode. + 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/{code}: + x-swagger-router-controller: access + put: + operationId: updateAccessClient + description: Updates an Access client. Requires admin authentication. Allowed fields include isActive, isListedInApp, subscription dates, and branding. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: code + type: string + in: path + required: true + description: Access code 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/{code}/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: code + type: string + in: path + required: true + description: Access code 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" # complex objects have schema definitions definitions: AnalyticsReport: @@ -1945,4 +2122,242 @@ definitions: - phrase properties: phrase: - type: string \ No newline at end of file + type: string + AccessClientCreate: + type: object + required: + - code + - clientName + - rootBoardId + - subscriptionStart + - subscriptionEnd + properties: + code: + type: string + description: Unique code for the access client (uppercase) + example: ABC123 + clientName: + type: string + description: Name of the client organization + example: Company Name + clientContact: + type: string + description: Contact information for the client + brandColor: + type: string + description: Brand color for the client (hex color) + example: "#FF5733" + rootBoardId: + type: string + description: ID of the root board + subscriptionStart: + type: string + format: date-time + description: Subscription start date + subscriptionEnd: + type: string + format: date-time + description: Subscription end date + boardIds: + type: array + items: + type: string + description: Array of board IDs to associate with this access code + AccessClientUpdate: + type: object + properties: + isActive: + type: boolean + description: Whether the client is active + isListedInApp: + type: boolean + description: Whether the client is listed in the app + 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 + clientContact: + type: string + description: Contact information for the client + brandColor: + type: string + description: Brand color for the client (hex color) + AccessClientResponse: + type: object + properties: + id: + type: string + description: Client ID + code: + type: string + description: Access code + clientName: + type: string + description: Client name + clientContact: + type: string + description: Client contact information + brandColor: + type: string + description: Brand color (hex) + rootBoardId: + type: string + description: Root board ID + isActive: + type: boolean + description: Whether client is active + isListedInApp: + type: boolean + description: Whether client is listed in app + subscriptionStart: + type: string + format: date-time + description: Subscription start date + subscriptionEnd: + type: string + format: date-time + description: Subscription end date + accessCount: + type: integer + description: Number of times accessed + lastAccessAt: + type: string + format: date-time + description: Last access timestamp + createdBy: + type: string + description: ID of user who created the client + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + AccessClientsListResponse: + type: object + required: + - total + - data + properties: + total: + type: integer + description: Total number of clients + data: + type: array + items: + type: object + properties: + code: + type: string + description: Access code + clientName: + type: string + description: Client name + brandColor: + type: string + description: Brand color (hex) + 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 + code: + type: string + clientName: + type: string + clientContact: + type: string + brandColor: + type: string + rootBoardId: + type: string + isActive: + type: boolean + isListedInApp: + type: boolean + subscriptionStart: + type: string + format: date-time + subscriptionEnd: + type: string + format: date-time + accessCount: + type: integer + lastAccessAt: + type: string + format: date-time + createdBy: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + boardCount: + type: integer + description: Number of boards associated with 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: + code: + type: string + description: Access code + name: + type: string + description: Client name + color: + type: string + description: Brand color (hex) + boards: + type: array + items: + $ref: "#/definitions/Board" + description: All boards associated with this access code + rootBoardId: + type: string + description: ID of the root board \ No newline at end of file From 34cec714db6687784dfedf35406fbfb326e31aeb Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:58:15 -0300 Subject: [PATCH 016/113] Add Postman collection for Cboard Access admin endpoints and public functionality --- test/postman/CboardAccess.collection.json | 481 ++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 test/postman/CboardAccess.collection.json diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json new file mode 100644 index 00000000..e1d00da9 --- /dev/null +++ b/test/postman/CboardAccess.collection.json @@ -0,0 +1,481 @@ +{ + "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('code');", + " pm.expect(jsonData).to.have.property('clientName');", + " pm.expect(jsonData).to.have.property('rootBoardId');", + "});", + "", + "// Save the code for later requests", + "if (pm.response.code === 201) {", + " pm.environment.set(\"access_code\", pm.response.json().code);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{AUTH_TOKEN}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"TEST01\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\",\n \"boardIds\": [\"{{root_board_id}}\"]\n}" + }, + "url": { + "raw": "{{API_URL}}/admin/access-clients", + "host": [ + "{{API_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('code');", + " pm.expect(client).to.have.property('clientName');", + " 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 {{AUTH_TOKEN}}", + "type": "text" + } + ], + "url": { + "raw": "{{API_URL}}/admin/access-clients", + "host": [ + "{{API_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('code');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{AUTH_TOKEN}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientContact\": \"newemail@testcoffee.com\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true,\n \"isListedInApp\": true\n}" + }, + "url": { + "raw": "{{API_URL}}/admin/access-clients/{{access_code}}", + "host": [ + "{{API_URL}}" + ], + "path": [ + "admin", + "access-clients", + "{{access_code}}" + ] + }, + "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 {{AUTH_TOKEN}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isActive\": false\n}" + }, + "url": { + "raw": "{{API_URL}}/admin/access-clients/{{access_code}}", + "host": [ + "{{API_URL}}" + ], + "path": [ + "admin", + "access-clients", + "{{access_code}}" + ] + }, + "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 {{AUTH_TOKEN}}", + "type": "text" + } + ], + "url": { + "raw": "{{API_URL}}/admin/access-clients/{{access_code}}/stats", + "host": [ + "{{API_URL}}" + ], + "path": [ + "admin", + "access-clients", + "{{access_code}}", + "stats" + ] + }, + "description": "Retrieves detailed statistics for a specific Cboard Access client. Requires admin authentication." + }, + "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(\"Only active and listed clients are returned\", function () {", + " var clients = pm.response.json().data;", + " clients.forEach(function(client) {", + " pm.expect(client).to.have.property('code');", + " pm.expect(client).to.have.property('clientName');", + " pm.expect(client).to.have.property('brandColor');", + " pm.expect(client).to.have.property('rootBoard');", + " });", + "});", + "", + "// Save first code for testing board access", + "if (pm.response.json().data.length > 0) {", + " pm.environment.set(\"test_access_code\", pm.response.json().data[0].code);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{API_URL}}/access/clients", + "host": [ + "{{API_URL}}" + ], + "path": [ + "access", + "clients" + ] + }, + "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": "{{API_URL}}/access/{{access_code}}", + "host": [ + "{{API_URL}}" + ], + "path": [ + "access", + "{{access_code}}" + ] + }, + "description": "Public endpoint that retrieves all boards for a specific access code. 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.test(\"Response contains token\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('token');", + "});", + "", + "// Save the token", + "if (pm.response.code === 200) {", + " pm.environment.set(\"AUTH_TOKEN\", pm.response.json().token);", + " console.log(\"Auth token saved!\");", + "}" + ], + "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": "{{API_URL}}/user/login", + "host": [ + "{{API_URL}}" + ], + "path": [ + "user", + "login" + ] + }, + "description": "Login to obtain an authentication token. Update the email and password with valid admin credentials." + }, + "response": [] + } + ] + } + ], + "variable": [ + { + "key": "API_URL", + "value": "http://localhost:10010", + "type": "string" + }, + { + "key": "AUTH_TOKEN", + "value": "", + "type": "string" + }, + { + "key": "access_code", + "value": "TEST01", + "type": "string" + }, + { + "key": "root_board_id", + "value": "", + "type": "string" + } + ] +} From bba2b01fa1455c92307eea359da334d952ed3c25 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 2 Apr 2026 13:58:25 -0300 Subject: [PATCH 017/113] Add Postman collection for Cboard Access feature with setup instructions and usage workflow --- test/postman/CboardAccess.README.md | 211 ++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 test/postman/CboardAccess.README.md diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md new file mode 100644 index 00000000..3bdad0bf --- /dev/null +++ b/test/postman/CboardAccess.README.md @@ -0,0 +1,211 @@ +# 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 +- **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 + +### 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 + +### 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 | +|----------|---------------|-------------| +| `API_URL` | `http://localhost:10010` | Base API URL | +| `AUTH_TOKEN` | (empty) | Authentication token for admin endpoints | +| `access_code` | `TEST01` | Access code for testing | +| `root_board_id` | (empty) | Board ID to use as root board | + +### 3. Optional: Create Environment + +You can create a custom environment for different deployment targets: + +**Local Development:** +```json +{ + "API_URL": "http://localhost:10010", + "root_board_id": "your-board-id-here" +} +``` + +**QA Environment:** +```json +{ + "API_URL": "https://api-qa.cboard.io", + "root_board_id": "qa-board-id" +} +``` + +**Production:** +```json +{ + "API_URL": "https://api.cboard.io", + "root_board_id": "production-board-id" +} +``` + +## 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 `AUTH_TOKEN` variable will be automatically set from the response + +### Creating a Client + +1. Run **Create Access Client** with your desired configuration: + ```json + { + "code": "CAFE01", + "clientName": "Coffee Shop Downtown", + "clientContact": "manager@coffee.com", + "brandColor": "#8B4513", + "rootBoardId": "{{root_board_id}}", + "subscriptionStart": "2026-04-01T00:00:00.000Z", + "subscriptionEnd": "2027-04-01T00:00:00.000Z", + "boardIds": ["{{root_board_id}}"] + } + ``` + +2. The response will include the created client +3. The `access_code` variable will be automatically updated + +### Testing the Client + +1. **Admin View**: Run "List All Clients" to see the client 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 +- Use **Deactivate Client** to disable access without deleting + +## Request Details + +### Create Access Client + +**Required Fields:** +- `code`: Unique identifier (uppercase, alphanumeric) +- `clientName`: Display name +- `rootBoardId`: ID of the main board +- `subscriptionStart`: Start date (ISO 8601) +- `subscriptionEnd`: End date (ISO 8601) + +**Optional Fields:** +- `clientContact`: Contact information +- `brandColor`: Hex color code (default: `#1976d2`) +- `boardIds`: Array of board IDs to associate + +### Update Client + +**Updatable Fields:** +- `clientName` +- `clientContact` +- `brandColor` +- `isActive` (boolean) +- `isListedInApp` (boolean) +- `subscriptionStart` +- `subscriptionEnd` + +### Public Endpoints + +These endpoints don't require authentication and simulate real user access: + +- **GET /access/clients**: Returns only active, listed clients with valid subscriptions +- **GET /access/:code**: 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 and IDs for subsequent requests + +## Common Scenarios + +### Scenario 1: New Client Onboarding + +1. Login to get auth token +2. Create access client +3. Verify in "List All Clients" +4. Test public 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 + +## Troubleshooting + +### "Invalid access code" Error +- Ensure the client is active (`isActive: true`) +- Check subscription dates are valid +- Verify `isListedInApp` is true for public listing + +### Authentication Errors +- Ensure `AUTH_TOKEN` is set (run login request) +- Check token hasn't expired +- Verify user has admin privileges + +### 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 + +- [Implementation Plan](../../CBOARD_ACCESS_IMPLEMENTATION_PLAN.md) +- [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 +- Public endpoints are rate-limited (if configured) +- Access count is automatically incremented on each board access +- Subscription dates are checked against current server time From ca6d1c44316728324518ce59ac893d89cb8f6e7e Mon Sep 17 00:00:00 2001 From: Agus Date: Sun, 5 Apr 2026 18:57:01 -0300 Subject: [PATCH 018/113] Add admin user advertisement --- test/postman/CboardAccess.README.md | 32 ++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 3bdad0bf..9959b94c 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -24,6 +24,19 @@ Cboard Access allows businesses to offer AAC boards in their locations via QR co ## 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 @@ -182,6 +195,23 @@ Each request includes automated tests: ## Troubleshooting +### AUTH_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 @@ -190,7 +220,7 @@ Each request includes automated tests: ### Authentication Errors - Ensure `AUTH_TOKEN` is set (run login request) - Check token hasn't expired -- Verify user has admin privileges +- Verify user has admin privileges (role: "admin") ### Board Not Found - Ensure `root_board_id` exists in the database From a0a0cba03ada1e1372b0103dea823c9da1569680 Mon Sep 17 00:00:00 2001 From: Agus Date: Sun, 5 Apr 2026 18:58:39 -0300 Subject: [PATCH 019/113] Chanegd url, token and where to save the token --- test/postman/CboardAccess.collection.json | 80 +++++++++++------------ 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index e1d00da9..045293dd 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -28,9 +28,18 @@ " pm.expect(jsonData).to.have.property('rootBoardId');", "});", "", + "pm.test(\"Response contains auto-discovered boards\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('linkedBoardsCount');", + " pm.expect(jsonData).to.have.property('linkedBoardIds');", + " pm.expect(jsonData.linkedBoardsCount).to.be.at.least(1);", + " console.log(\"Auto-discovered \" + jsonData.linkedBoardsCount + \" boards\");", + "});", + "", "// Save the code for later requests", "if (pm.response.code === 201) {", - " pm.environment.set(\"access_code\", pm.response.json().code);", + " pm.collectionVariables.set(\"access_code\", pm.response.json().code);", + " console.log(\"Access code saved: \" + pm.response.json().code);", "}" ], "type": "text/javascript" @@ -46,18 +55,18 @@ }, { "key": "Authorization", - "value": "Bearer {{AUTH_TOKEN}}", + "value": "Bearer {{token}}", "type": "text" } ], "body": { "mode": "raw", - "raw": "{\n \"code\": \"TEST01\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\",\n \"boardIds\": [\"{{root_board_id}}\"]\n}" + "raw": "{\n \"code\": \"TEST01\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" }, "url": { - "raw": "{{API_URL}}/admin/access-clients", + "raw": "{{url}}/admin/access-clients", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "admin", @@ -106,14 +115,14 @@ "header": [ { "key": "Authorization", - "value": "Bearer {{AUTH_TOKEN}}", + "value": "Bearer {{token}}", "type": "text" } ], "url": { - "raw": "{{API_URL}}/admin/access-clients", + "raw": "{{url}}/admin/access-clients", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "admin", @@ -153,7 +162,7 @@ }, { "key": "Authorization", - "value": "Bearer {{AUTH_TOKEN}}", + "value": "Bearer {{token}}", "type": "text" } ], @@ -162,9 +171,9 @@ "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientContact\": \"newemail@testcoffee.com\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true,\n \"isListedInApp\": true\n}" }, "url": { - "raw": "{{API_URL}}/admin/access-clients/{{access_code}}", + "raw": "{{url}}/admin/access-clients/{{access_code}}", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "admin", @@ -205,7 +214,7 @@ }, { "key": "Authorization", - "value": "Bearer {{AUTH_TOKEN}}", + "value": "Bearer {{token}}", "type": "text" } ], @@ -214,9 +223,9 @@ "raw": "{\n \"isActive\": false\n}" }, "url": { - "raw": "{{API_URL}}/admin/access-clients/{{access_code}}", + "raw": "{{url}}/admin/access-clients/{{access_code}}", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "admin", @@ -263,14 +272,14 @@ "header": [ { "key": "Authorization", - "value": "Bearer {{AUTH_TOKEN}}", + "value": "Bearer {{token}}", "type": "text" } ], "url": { - "raw": "{{API_URL}}/admin/access-clients/{{access_code}}/stats", + "raw": "{{url}}/admin/access-clients/{{access_code}}/stats", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "admin", @@ -319,7 +328,8 @@ "", "// Save first code for testing board access", "if (pm.response.json().data.length > 0) {", - " pm.environment.set(\"test_access_code\", pm.response.json().data[0].code);", + " pm.collectionVariables.set(\"test_access_code\", pm.response.json().data[0].code);", + " console.log(\"Test access code saved: \" + pm.response.json().data[0].code);", "}" ], "type": "text/javascript" @@ -330,9 +340,9 @@ "method": "GET", "header": [], "url": { - "raw": "{{API_URL}}/access/clients", + "raw": "{{url}}/access/clients", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "access", @@ -382,9 +392,9 @@ "method": "GET", "header": [], "url": { - "raw": "{{API_URL}}/access/{{access_code}}", + "raw": "{{url}}/access/{{access_code}}", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "access", @@ -412,16 +422,8 @@ " pm.response.to.have.status(200);", "});", "", - "pm.test(\"Response contains token\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('token');", - "});", - "", - "// Save the token", - "if (pm.response.code === 200) {", - " pm.environment.set(\"AUTH_TOKEN\", pm.response.json().token);", - " console.log(\"Auth token saved!\");", - "}" + "pm.globals.set(\"token\", pm.response.json().authToken);", + "console.log(\"Token saved: \" + pm.globals.get(\"token\").substring(0, 30) + \"...\");" ], "type": "text/javascript" } @@ -440,13 +442,14 @@ "raw": "{\n \"email\": \"admin@cboard.io\",\n \"password\": \"your-password\"\n}" }, "url": { - "raw": "{{API_URL}}/user/login", + "raw": "{{url}}/user/login/", "host": [ - "{{API_URL}}" + "{{url}}" ], "path": [ "user", - "login" + "login", + "" ] }, "description": "Login to obtain an authentication token. Update the email and password with valid admin credentials." @@ -458,15 +461,10 @@ ], "variable": [ { - "key": "API_URL", + "key": "url", "value": "http://localhost:10010", "type": "string" }, - { - "key": "AUTH_TOKEN", - "value": "", - "type": "string" - }, { "key": "access_code", "value": "TEST01", From ab909da65cbe434eb8d724fc48aabae6b44633a5 Mon Sep 17 00:00:00 2001 From: Agus Date: Sun, 5 Apr 2026 18:59:40 -0300 Subject: [PATCH 020/113] Add functions to automatically link boards ids like folders to the root board --- api/controllers/access.js | 76 +++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 0eec1c65..7a216565 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -12,6 +12,58 @@ module.exports = { getAccessClientStats: getAccessClientStats }; +/** + * Recursively discovers all boards linked from a root board. + * Traverses tile.loadBoard references to find the complete board structure. + * @param {string} rootBoardId - The starting board ID + * @param {Set} visited - Set of already visited board IDs (prevents cycles) + * @returns {Promise} - Array of all discovered board IDs + */ +async function discoverLinkedBoards(rootBoardId, visited = new Set()) { + // Prevent infinite loops from circular references + if (visited.has(rootBoardId.toString())) { + return []; + } + visited.add(rootBoardId.toString()); + + const board = await Board.findById(rootBoardId); + if (!board) { + return []; + } + + const linkedBoardIds = []; + + // Find all tiles that link to other boards + if (board.tiles && Array.isArray(board.tiles)) { + for (const tile of board.tiles) { + if (tile.loadBoard) { + const linkedId = tile.loadBoard.toString(); + if (!visited.has(linkedId)) { + linkedBoardIds.push(linkedId); + // Recursively discover boards linked from this board + const nestedIds = await discoverLinkedBoards(linkedId, visited); + linkedBoardIds.push(...nestedIds); + } + } + } + } + + return linkedBoardIds; +} + +/** + * Gets all board IDs for an access client, starting from root. + * Returns rootBoardId + all recursively linked boards. + * @param {string} rootBoardId - The root board ID + * @returns {Promise} - Array of all board IDs + */ +async function getAllLinkedBoardIds(rootBoardId) { + const visited = new Set(); + const linkedIds = await discoverLinkedBoards(rootBoardId, visited); + // Return unique IDs including root + return [...new Set([rootBoardId.toString(), ...linkedIds])]; +} + /** * GET /access/clients * Lists active companies for Cboard Access listing in the app. @@ -151,7 +203,7 @@ async function createAccessClient(req, res) { rootBoardId, subscriptionStart, subscriptionEnd, - boardIds // Array of board IDs to associate + boardIds // Optional: Array of board IDs to associate. If not provided, auto-discovers linked boards. } = req.body; try { @@ -175,17 +227,27 @@ async function createAccessClient(req, res) { await client.save(); - // Mark the boards with the accessCode - // Ensure rootBoardId is always included even if boardIds is empty array - const allBoardIds = boardIds && boardIds.length > 0 - ? [...new Set([rootBoardId.toString(), ...boardIds.map(id => id.toString())])] - : [rootBoardId]; + // Determine which boards to associate with the access code + // If boardIds provided, use them; otherwise auto-discover all linked boards + let allBoardIds; + if (boardIds && boardIds.length > 0) { + allBoardIds = [...new Set([rootBoardId.toString(), ...boardIds.map(id => id.toString())])]; + } else { + // Auto-discover all boards linked from the root board + allBoardIds = await getAllLinkedBoardIds(rootBoardId); + } + await Board.updateMany( { _id: { $in: allBoardIds } }, { $set: { accessCode: code.toUpperCase() } } ); - return res.status(201).json(client.toJSON()); + // Return response with discovered board count + const response = client.toJSON(); + response.linkedBoardsCount = allBoardIds.length; + response.linkedBoardIds = allBoardIds; + + return res.status(201).json(response); } catch (err) { if (err.code === 11000) { return res.status(409).json({ message: 'Code already exists' }); From 2d6a58c38f441714161197d0cf5428f0225966af Mon Sep 17 00:00:00 2001 From: Agus Date: Sun, 5 Apr 2026 19:39:41 -0300 Subject: [PATCH 021/113] Added qrcode dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 48a09df6..dd82d05e 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "passport-facebook-token": "^4.0.0", "passport-google-oauth": "^2.0.0", "passport-google-token": "^0.1.2", + "qrcode": "^1.5.4", "pem": "^1.14.8", "rand-token": "0.4.0", "should": "^13.2.3", From e2a58cdd9cb05e64d87bf71583b6c55e969b07b2 Mon Sep 17 00:00:00 2001 From: Agus Date: Sun, 5 Apr 2026 19:39:54 -0300 Subject: [PATCH 022/113] QR generator script --- scripts/generate-qr.js | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 scripts/generate-qr.js diff --git a/scripts/generate-qr.js b/scripts/generate-qr.js new file mode 100644 index 00000000..b3001033 --- /dev/null +++ b/scripts/generate-qr.js @@ -0,0 +1,68 @@ +'use strict'; + +const QRCode = require('qrcode'); +const path = require('path'); + +require('dotenv').config(); + +const DEFAULT_BASE_URL = process.env.CBOARD_APP_URL || 'https://app.cboard.io'; +const QR_WIDTH = 500; + +/** + * Generates a QR code PNG for a Cboard Access code. + * + * Creates a QR code that links to the access URL for the given code. + * The QR code can be printed and displayed at client locations. + * + * @param {string} code - The access code (e.g., "CAFE01") + * @param {string} baseUrl - Base URL for the access link (default: CBOARD_APP_URL or https://app.cboard.io) + * @returns {Promise} + * + * @example + * // CLI usage: + * // node scripts/generate-qr.js CAFE01 + * // node scripts/generate-qr.js CAFE01 https://app.cboard.io + * // node scripts/generate-qr.js TEST01 http://localhost:3000 + * + * // Programmatic usage: + * await generateQRCode('CAFE01', 'https://app.cboard.io'); + * // Creates: CAFE01-qr.png in current directory + */ +async function generateQRCode(code, baseUrl) { + if (!code) { + console.error('Error: Access code is required'); + console.error('\nUsage: node scripts/generate-qr.js [BASE_URL]'); + console.error('Example: node scripts/generate-qr.js CAFE01 https://app.cboard.io'); + process.exit(1); + } + + const normalizedCode = code.toUpperCase(); + const url = `${baseUrl}/access/${normalizedCode}`; + const filename = `${normalizedCode}-qr.png`; + const outputPath = path.join(process.cwd(), filename); + + try { + await QRCode.toFile(outputPath, url, { + width: QR_WIDTH, + margin: 2, + errorCorrectionLevel: 'M', + color: { + dark: '#000000', + light: '#ffffff' + } + }); + } catch (error) { + console.error('Error generating QR code:', error.message); + process.exit(1); + } +} + +const args = process.argv.slice(2); +const code = args[0]; +const baseUrl = args[1] || DEFAULT_BASE_URL; + +if (require.main === module) { + generateQRCode(code, baseUrl); +} + +module.exports = { generateQRCode }; From 0bcd8cd1587d2fcf417fe591a1bd76cdf5ea6a01 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 10:35:04 -0300 Subject: [PATCH 023/113] Fix Swagger schemas to match actual API responses --- api/swagger/swagger.yaml | 56 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index e7b5ab8a..4e29ab6c 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2301,7 +2301,13 @@ definitions: brandColor: type: string rootBoardId: - type: string + type: object + description: Populated root board object + properties: + _id: + type: string + name: + type: string isActive: type: boolean isListedInApp: @@ -2318,7 +2324,15 @@ definitions: type: string format: date-time createdBy: - type: string + type: object + description: Populated user object who created the client + properties: + _id: + type: string + name: + type: string + email: + type: string createdAt: type: string format: date-time @@ -2356,8 +2370,42 @@ definitions: boards: type: array items: - $ref: "#/definitions/Board" + $ref: "#/definitions/AccessBoard" description: All boards associated with this access code rootBoardId: type: string - description: ID of the root board \ No newline at end of file + 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 + accessCode: + type: string + description: The access code associated with this board From 956a68e3812bfb6d1995d1f2df4dc3757bdd9b61 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 12:04:55 -0300 Subject: [PATCH 024/113] Verify if linked board exists before adding it --- api/controllers/access.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 7a216565..cab7e2f6 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -39,10 +39,14 @@ async function discoverLinkedBoards(rootBoardId, visited = new Set()) { if (tile.loadBoard) { const linkedId = tile.loadBoard.toString(); if (!visited.has(linkedId)) { - linkedBoardIds.push(linkedId); - // Recursively discover boards linked from this board - const nestedIds = await discoverLinkedBoards(linkedId, visited); - linkedBoardIds.push(...nestedIds); + // Verify the linked board exists before adding to avoid phantom entries + const linkedBoard = await Board.findById(linkedId); + if (linkedBoard) { + linkedBoardIds.push(linkedId); + // Recursively discover boards linked from this board + const nestedIds = await discoverLinkedBoards(linkedId, visited); + linkedBoardIds.push(...nestedIds); + } } } } From e46236d0ebf8be55bacea9502b5ca7642e80d192 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 12:05:45 -0300 Subject: [PATCH 025/113] Added linkedBoardsCount and linkedBoardIds to AccessClientResponse schema --- api/swagger/swagger.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 4e29ab6c..6d38b06f 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2242,6 +2242,14 @@ definitions: type: string format: date-time description: Last update timestamp + linkedBoardsCount: + type: integer + description: Number of boards auto-discovered and linked to this client (only in create response) + linkedBoardIds: + type: array + items: + type: string + description: Array of board IDs that were auto-discovered and linked (only in create response) AccessClientsListResponse: type: object required: From 262b3ac07bc25a8a05e682742c5a10c6d8b78049 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 12:07:03 -0300 Subject: [PATCH 026/113] Fixed variable mismatch (access_code instead of test_access_code) --- test/postman/CboardAccess.collection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 045293dd..e028f489 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -328,8 +328,8 @@ "", "// Save first code for testing board access", "if (pm.response.json().data.length > 0) {", - " pm.collectionVariables.set(\"test_access_code\", pm.response.json().data[0].code);", - " console.log(\"Test access code saved: \" + pm.response.json().data[0].code);", + " pm.collectionVariables.set(\"access_code\", pm.response.json().data[0].code);", + " console.log(\"Access code saved: \" + pm.response.json().data[0].code);", "}" ], "type": "text/javascript" From d043b14aff8d1d303bda9688d2d95c98c517add3 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 12:07:52 -0300 Subject: [PATCH 027/113] Fix variable names and remove false brand color --- test/postman/CboardAccess.README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 9959b94c..7c7f89d6 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -50,8 +50,8 @@ The collection includes these variables (can be edited in collection variables o | Variable | Default Value | Description | |----------|---------------|-------------| -| `API_URL` | `http://localhost:10010` | Base API URL | -| `AUTH_TOKEN` | (empty) | Authentication token for admin endpoints | +| `url` | `http://localhost:10010` | Base API URL | +| `token` | (set automatically) | Authentication token for admin endpoints (saved to globals on login) | | `access_code` | `TEST01` | Access code for testing | | `root_board_id` | (empty) | Board ID to use as root board | @@ -141,8 +141,8 @@ You can create a custom environment for different deployment targets: **Optional Fields:** - `clientContact`: Contact information -- `brandColor`: Hex color code (default: `#1976d2`) -- `boardIds`: Array of board IDs to associate +- `brandColor`: Hex color code +- `boardIds`: Array of board IDs to associate (if omitted, linked boards are auto-discovered from root board) ### Update Client From 0429ff36754aa5905bdfd4f191d0fb16a2328de8 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 6 Apr 2026 18:07:39 -0300 Subject: [PATCH 028/113] Add log output on success and throw errors --- scripts/generate-qr.js | 63 +++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/scripts/generate-qr.js b/scripts/generate-qr.js index b3001033..3def1e0f 100644 --- a/scripts/generate-qr.js +++ b/scripts/generate-qr.js @@ -16,7 +16,8 @@ const QR_WIDTH = 500; * * @param {string} code - The access code (e.g., "CAFE01") * @param {string} baseUrl - Base URL for the access link (default: CBOARD_APP_URL or https://app.cboard.io) - * @returns {Promise} + * @returns {Promise<{url: string, outputPath: string}>} - The encoded URL and output file path + * @throws {Error} If code is not provided or QR generation fails * * @example * // CLI usage: @@ -25,15 +26,12 @@ const QR_WIDTH = 500; * // node scripts/generate-qr.js TEST01 http://localhost:3000 * * // Programmatic usage: - * await generateQRCode('CAFE01', 'https://app.cboard.io'); + * const { url, outputPath } = await generateQRCode('CAFE01', 'https://app.cboard.io'); * // Creates: CAFE01-qr.png in current directory */ -async function generateQRCode(code, baseUrl) { +async function generateQRCode(code, baseUrl = DEFAULT_BASE_URL) { if (!code) { - console.error('Error: Access code is required'); - console.error('\nUsage: node scripts/generate-qr.js [BASE_URL]'); - console.error('Example: node scripts/generate-qr.js CAFE01 https://app.cboard.io'); - process.exit(1); + throw new Error('Access code is required'); } const normalizedCode = code.toUpperCase(); @@ -41,28 +39,41 @@ async function generateQRCode(code, baseUrl) { const filename = `${normalizedCode}-qr.png`; const outputPath = path.join(process.cwd(), filename); - try { - await QRCode.toFile(outputPath, url, { - width: QR_WIDTH, - margin: 2, - errorCorrectionLevel: 'M', - color: { - dark: '#000000', - light: '#ffffff' - } - }); - } catch (error) { - console.error('Error generating QR code:', error.message); - process.exit(1); - } -} + await QRCode.toFile(outputPath, url, { + width: QR_WIDTH, + margin: 2, + errorCorrectionLevel: 'M', + color: { + dark: '#000000', + light: '#ffffff' + } + }); -const args = process.argv.slice(2); -const code = args[0]; -const baseUrl = args[1] || DEFAULT_BASE_URL; + return { url, outputPath }; +} if (require.main === module) { - generateQRCode(code, baseUrl); + const args = process.argv.slice(2); + const code = args[0]; + const baseUrl = args[1] || DEFAULT_BASE_URL; + + if (!code) { + console.error('Error: Access code is required'); + console.error('\nUsage: node scripts/generate-qr.js [BASE_URL]'); + console.error('Example: node scripts/generate-qr.js CAFE01 https://app.cboard.io'); + process.exit(1); + } + + generateQRCode(code, 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 }; From dcd0f8d3c48fca8b57e92f0431efd3668ae7e705 Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 8 Apr 2026 16:53:15 -0300 Subject: [PATCH 029/113] Changed access client schema to include access point --- api/models/AccessClient.js | 77 +++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js index 32ac1fad..41a7069c 100644 --- a/api/models/AccessClient.js +++ b/api/models/AccessClient.js @@ -3,39 +3,62 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const accessPointSchema = new Schema( + { + code: { + type: String, + required: true, + trim: true, + uppercase: true + }, + rootBoardId: { + type: Schema.Types.ObjectId, + ref: 'Board', + required: true + }, + isListedInApp: { + type: Boolean, + default: true + }, + accessCount: { + type: Number, + default: 0 + }, + lastAccessAt: { + type: Date, + default: null + } + }, + { _id: true } +); + const ACCESS_CLIENT_SCHEMA_DEFINITION = { - code: { + slug: { type: String, unique: true, required: true, trim: true, - uppercase: true + lowercase: true }, - clientName: { - type: String, - required: true, - trim: true - }, - clientContact: { - type: String, - trim: true + client: { + name: { + type: String, + required: true, + trim: true + }, + contact: { + type: String, + trim: true + } }, brandColor: { - type: String - }, - rootBoardId: { - type: Schema.Types.ObjectId, - ref: 'Board', - required: true + type: String, + default: '#1976d2' }, isActive: { type: Boolean, default: true }, - isListedInApp: { - type: Boolean, - default: true - }, subscriptionStart: { type: Date, required: true @@ -44,18 +67,12 @@ const ACCESS_CLIENT_SCHEMA_DEFINITION = { type: Date, required: true }, - accessCount: { - type: Number, - default: 0 - }, - lastAccessAt: { - type: Date - }, createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true - } + }, + accessPoints: [accessPointSchema] }; const ACCESS_CLIENT_SCHEMA_OPTIONS = { @@ -66,7 +83,7 @@ const ACCESS_CLIENT_SCHEMA_OPTIONS = { toJSON: { virtuals: true, versionKey: false, - transform: function (doc, ret) { + transform: function(doc, ret) { ret.id = ret._id; delete ret._id; } @@ -78,8 +95,6 @@ const accessClientSchema = new Schema( ACCESS_CLIENT_SCHEMA_OPTIONS ); -accessClientSchema.index({ isActive: 1, isListedInApp: 1 }); - const AccessClient = mongoose.model('AccessClient', accessClientSchema); module.exports = AccessClient; From 8c78fed864b967b9bfb322cdaaf8327f4d68e759 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 14:28:53 -0300 Subject: [PATCH 030/113] Changed access client model to delete access point form it --- api/models/AccessClient.js | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js index 41a7069c..a7f84095 100644 --- a/api/models/AccessClient.js +++ b/api/models/AccessClient.js @@ -3,35 +3,6 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const accessPointSchema = new Schema( - { - code: { - type: String, - required: true, - trim: true, - uppercase: true - }, - rootBoardId: { - type: Schema.Types.ObjectId, - ref: 'Board', - required: true - }, - isListedInApp: { - type: Boolean, - default: true - }, - accessCount: { - type: Number, - default: 0 - }, - lastAccessAt: { - type: Date, - default: null - } - }, - { _id: true } -); - const ACCESS_CLIENT_SCHEMA_DEFINITION = { slug: { type: String, @@ -71,8 +42,7 @@ const ACCESS_CLIENT_SCHEMA_DEFINITION = { type: Schema.Types.ObjectId, ref: 'User', required: true - }, - accessPoints: [accessPointSchema] + } }; const ACCESS_CLIENT_SCHEMA_OPTIONS = { From 7d55b048e1bdb5b3f35ee5f71724c20c619ea3c8 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 14:29:56 -0300 Subject: [PATCH 031/113] Created access point model --- api/models/AccessPoint.js | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 api/models/AccessPoint.js diff --git a/api/models/AccessPoint.js b/api/models/AccessPoint.js new file mode 100644 index 00000000..b4fdb78d --- /dev/null +++ b/api/models/AccessPoint.js @@ -0,0 +1,57 @@ +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ACCESS_POINT_SCHEMA_DEFINITION = { + code: { + type: String, + required: true, + trim: true, + uppercase: true, + unique: true, + index: true + }, + accessClient: { + 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 + } +}; + +const ACCESS_POINT_SCHEMA_OPTIONS = { + timestamps: true, + toObject: { + virtuals: true + }, + toJSON: { + virtuals: true, + versionKey: false, + transform: function(doc, ret) { + ret.id = ret._id; + delete ret._id; + } + } +}; + +const accessPointSchema = new Schema( + ACCESS_POINT_SCHEMA_DEFINITION, + ACCESS_POINT_SCHEMA_OPTIONS +); + +const AccessPoint = mongoose.model('AccessPoint', accessPointSchema); + +module.exports = AccessPoint; From e3407fa27a29e53fbfbb71ce0e6405439212c38e Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 15:43:52 -0300 Subject: [PATCH 032/113] Renamed accessCode to accessPointCode --- api/models/Board.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index 94d3bf73..b6ec4da8 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -63,10 +63,11 @@ const BOARD_SCHEMA_DEFINITION = { default: false }, grid: {}, - accessCode: { + accessPointCode: { type: String, trim: true, - uppercase: true + uppercase: true, + default: null } }; @@ -86,8 +87,8 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse index skips documents where accessCode is missing, optimizing queries for Access boards -boardSchema.index({ accessCode: 1 }, { sparse: true }); +// Sparse index skips documents where accessPointCode is null/missing, optimizing queries for Access boards +boardSchema.index({ accessPointCode: 1 }, { sparse: true }); const validatePresenceOf = value => value && value.length; From 699478ab745bcd1935758c83cef4f330845c04e9 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 15:44:04 -0300 Subject: [PATCH 033/113] Renamed accessCode to accessPointCode in tests --- test/controllers/board.js | 40 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index c5841a5f..a8af8c1a 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,11 +252,11 @@ describe('Board API calls', function () { }); }); - describe('accessCode field behavior', function () { - it('should normalize accessCode to uppercase when creating a board', async function () { + describe('accessPointCode field behavior', function () { + it('should normalize accessPointCode to uppercase when creating a board', async function () { const boardWithAccessCode = { ...helper.boardData, - accessCode: 'cafe01' + accessPointCode: 'cafe01' }; const res = await request(server) .post('/board') @@ -266,14 +266,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessCode'); - res.body.accessCode.should.equal('CAFE01'); + res.body.should.have.property('accessPointCode'); + res.body.accessPointCode.should.equal('CAFE01'); }); - it('should trim whitespace from accessCode', async function () { + it('should trim whitespace from accessPointCode', async function () { const boardWithAccessCode = { ...helper.boardData, - accessCode: ' test01 ' + accessPointCode: ' test01 ' }; const res = await request(server) .post('/board') @@ -283,13 +283,13 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessCode'); - res.body.accessCode.should.equal('TEST01'); + res.body.should.have.property('accessPointCode'); + res.body.accessPointCode.should.equal('TEST01'); }); - it('should not include accessCode when not provided', async function () { + it('should default accessPointCode to null when not provided', async function () { const boardWithoutAccessCode = { ...helper.boardData }; - delete boardWithoutAccessCode.accessCode; + delete boardWithoutAccessCode.accessPointCode; const res = await request(server) .post('/board') @@ -299,15 +299,15 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.not.have.property('accessCode'); + res.body.accessPointCode.should.equal(null); }); - it('should update accessCode on an existing board', async function () { + it('should update accessPointCode on an existing board', async function () { const boardId = await helper.createMochaBoard(server, user.token); const updateData = { ...helper.boardData, - accessCode: 'updated01' + accessPointCode: 'updated01' }; const res = await request(server) .put('/board/' + boardId) @@ -317,14 +317,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessCode'); - res.body.accessCode.should.equal('UPDATED01'); + res.body.should.have.property('accessPointCode'); + res.body.accessPointCode.should.equal('UPDATED01'); }); - it('should return accessCode in GET response when set', async function () { + it('should return accessPointCode in GET response when set', async function () { const boardWithAccessCode = { ...helper.boardData, - accessCode: 'gettest01' + accessPointCode: 'gettest01' }; const createRes = await request(server) .post('/board') @@ -343,8 +343,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - getRes.body.should.have.property('accessCode'); - getRes.body.accessCode.should.equal('GETTEST01'); + getRes.body.should.have.property('accessPointCode'); + getRes.body.accessPointCode.should.equal('GETTEST01'); }); }); From 985c1a89084374934c7384bf2a762f3b6156c030 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 15:53:28 -0300 Subject: [PATCH 034/113] Added linkedBoardsIds field in access point model --- api/models/AccessPoint.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/models/AccessPoint.js b/api/models/AccessPoint.js index b4fdb78d..42a8881b 100644 --- a/api/models/AccessPoint.js +++ b/api/models/AccessPoint.js @@ -29,6 +29,11 @@ const ACCESS_POINT_SCHEMA_DEFINITION = { lastAccessAt: { type: Date, default: null + }, + linkedBoardsIds: { + type: [Schema.Types.ObjectId], + ref: 'Board', + default: [] } }; From 5c986ecc8f3dccdf7e8e21e82a4b57ac31c0daf5 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 16:08:04 -0300 Subject: [PATCH 035/113] Modify access.js to adapt to the new AccessClient and AccessPoint models --- api/controllers/access.js | 102 ++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 488a4373..fba493d3 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -1,6 +1,7 @@ 'use strict'; const AccessClient = require('../models/AccessClient'); +const AccessPoint = require('../models/AccessPoint'); const Board = require('../models/Board'); module.exports = { @@ -10,37 +11,45 @@ module.exports = { /** * GET /access/clients - * Lists active companies for Cboard Access listing in the app. - * Returns clients where isActive=true, isListedInApp=true, and subscription is valid. - * Populates rootBoard basic info (name, caption, tiles count). - * Sorted by clientName. + * Lists all active clients with valid subscriptions and their access points. + * Returns each client with its access points 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, - isListedInApp: true, subscriptionStart: { $lte: now }, subscriptionEnd: { $gte: now } + }).select('slug client brandColor'); + + const accessPoints = await AccessPoint.find({ + accessClient: { $in: clients.map(c => c._id) } }) .populate('rootBoardId', 'name caption tiles') - .select('code clientName brandColor rootBoardId') - .sort({ clientName: 1 }); + .select('code rootBoardId accessClient'); - const result = clients.map(client => ({ - code: client.code, - clientName: client.clientName, - brandColor: client.brandColor, - rootBoard: client.rootBoardId - ? { - id: client.rootBoardId._id, - name: client.rootBoardId.name, - caption: client.rootBoardId.caption, - tilesCount: client.rootBoardId.tiles?.length || 0 - } - : null - })); + const result = clients + .map(client => ({ + slug: client.slug, + clientName: client.client.name, + brandColor: client.brandColor, + accessPoints: accessPoints + .filter(ap => ap.accessClient.toString() === client._id.toString()) + .map(ap => ({ + code: ap.code, + rootBoard: ap.rootBoardId + ? { + id: ap.rootBoardId._id, + name: ap.rootBoardId.name, + caption: ap.rootBoardId.caption, + tilesCount: ap.rootBoardId.tiles?.length || 0 + } + : null + })) + })) + .sort((a, b) => a.clientName.localeCompare(b.clientName)); return res.status(200).json({ total: result.length, @@ -58,32 +67,41 @@ async function getClients(req, res) { * GET /access/:code * Gets ALL boards for an access code in a single request. * This enables instant frontend navigation without additional requests. - * Validates code exists and subscription is active. - * Returns client info (code, name, color), all boards array, and rootBoardId. - * Increments accessCount and updates lastAccessAt. + * Validates code exists and client subscription is active. + * Returns client info (slug, name, color), all boards array, and rootBoardId. + * Increments viewsCount and updates lastAccessAt on the AccessPoint. */ async function getAccessBoard(req, res) { const code = req.swagger.params.code.value.toUpperCase(); try { const now = new Date(); - const client = await AccessClient.findOne({ - code: code, - isActive: true, - subscriptionStart: { $lte: now }, - subscriptionEnd: { $gte: now } - }); + const accessPoint = await AccessPoint.findOne({ code }).populate( + 'accessClient' + ); - if (!client) { + if (!accessPoint) { + return res.status(404).json({ + message: 'Invalid access code' + }); + } + + const client = accessPoint.accessClient; + if ( + !client || + !client.isActive || + client.subscriptionStart > now || + client.subscriptionEnd < now + ) { return res.status(404).json({ message: 'Invalid or expired access code' }); } - // Get ALL boards with this accessCode (exclude PII fields for public endpoint) - const boards = await Board.find({ accessCode: code }).select( - '-email -author' - ); + // Fetch all linked boards (exclude PII fields for public endpoint) + const boards = await Board.find({ + _id: { $in: accessPoint.linkedBoardsIds } + }).select('-email -author'); if (!boards || boards.length === 0) { return res.status(404).json({ @@ -91,9 +109,9 @@ async function getAccessBoard(req, res) { }); } - // Verify the rootBoard exists + // Verify the rootBoard exists among the linked boards const rootBoard = boards.find( - b => b._id.toString() === client.rootBoardId.toString() + b => b._id.toString() === accessPoint.rootBoardId.toString() ); if (!rootBoard) { return res.status(404).json({ @@ -102,22 +120,22 @@ async function getAccessBoard(req, res) { } // Track access analytics atomically to avoid lost updates under concurrency - await AccessClient.updateOne( - { _id: client._id }, + await AccessPoint.updateOne( + { _id: accessPoint._id }, { - $inc: { accessCount: 1 }, + $inc: { viewsCount: 1 }, $set: { lastAccessAt: new Date() } } ); return res.status(200).json({ client: { - code: client.code, - name: client.clientName, + code: client.slug, + name: client.client.name, color: client.brandColor }, boards: boards.map(b => b.toJSON()), - rootBoardId: client.rootBoardId.toString() + rootBoardId: accessPoint.rootBoardId.toString() }); } catch (err) { return res.status(500).json({ From 50c604b36767822ff63c6b228a1edf6b8e89d704 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 16:40:53 -0300 Subject: [PATCH 036/113] Refactor access.js to adapt to AccessClient and AccessPoint models and added auto-discovery for linked boards --- api/controllers/access.js | 192 ++++++++++++++++++++++++++++---------- 1 file changed, 145 insertions(+), 47 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index dbc69560..441dcb1e 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -10,9 +10,44 @@ module.exports = { createAccessClient: createAccessClient, listAccessClients: listAccessClients, updateAccessClient: updateAccessClient, + updateAccessPoint: updateAccessPoint, 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 * Lists all active clients with valid subscriptions and their access points. @@ -154,22 +189,22 @@ async function getAccessBoard(req, res) { // ============================================ /** - * POST /api/admin/access-clients - * Creates a new Access client. + * POST /admin/access-clients + * Creates a new Access client and its first Access point. * Requires admin authentication (enforced by Swagger middleware). - * Creates AccessClient and updates boards with accessCode. + * Creates AccessClient, creates AccessPoint, and updates boards with accessPointCode. * Validates rootBoardId exists. */ async function createAccessClient(req, res) { const { - code, + slug, clientName, clientContact, brandColor, rootBoardId, subscriptionStart, subscriptionEnd, - boardIds // Array of board IDs to associate + accessPointCode } = req.body; try { @@ -181,11 +216,9 @@ async function createAccessClient(req, res) { // Create the client const client = new AccessClient({ - code: code.toUpperCase(), - clientName, - clientContact, + slug, + client: { name: clientName, contact: clientContact }, brandColor, - rootBoardId, subscriptionStart: new Date(subscriptionStart), subscriptionEnd: new Date(subscriptionEnd), createdBy: req.user.id @@ -193,20 +226,32 @@ async function createAccessClient(req, res) { await client.save(); - // Mark the boards with the accessCode - // Ensure rootBoardId is always included even if boardIds is empty array - const allBoardIds = boardIds && boardIds.length > 0 - ? [...new Set([rootBoardId.toString(), ...boardIds.map(id => id.toString())])] - : [rootBoardId]; + // Auto-discover all boards reachable from root via tile.loadBoard links + const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); + + // Create the access point + const accessPoint = new AccessPoint({ + code: accessPointCode.toUpperCase(), + accessClient: client._id, + rootBoardId, + linkedBoardsIds: linkedBoardIds + }); + + await accessPoint.save(); + + // Mark all discovered boards with the access point code await Board.updateMany( - { _id: { $in: allBoardIds } }, - { $set: { accessCode: code.toUpperCase() } } + { _id: { $in: linkedBoardIds } }, + { $set: { accessPointCode: accessPoint.code } } ); - return res.status(201).json(client.toJSON()); + return res.status(201).json({ ...client.toJSON(), accessPoint: accessPoint.toJSON() }); } catch (err) { if (err.code === 11000) { - return res.status(409).json({ message: 'Code already exists' }); + 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', @@ -216,33 +261,31 @@ async function createAccessClient(req, res) { } /** - * GET /api/admin/access-clients + * GET /admin/access-clients * Lists all Access clients with stats. * Returns all clients with board counts, expiry status. - * Populates rootBoard and createdBy. + * Populates createdBy. Board count derived from AccessPoint.linkedBoardsIds. */ async function listAccessClients(req, res) { try { const clients = await AccessClient.find() - .populate('rootBoardId', 'name') .populate('createdBy', 'name email') .sort({ createdAt: -1 }); - // Precompute board counts for all clients in a single query to avoid N+1 - const accessCodes = clients.map(client => client.code); - const boardCounts = await Board.aggregate([ - { $match: { accessCode: { $in: accessCodes } } }, - { $group: { _id: '$accessCode', count: { $sum: 1 } } } - ]); + // Fetch access points and sum linkedBoardsIds per client to avoid N+1 + const clientIds = clients.map(c => c._id); + const accessPoints = await AccessPoint.find({ accessClient: { $in: clientIds } }); - const boardCountMap = boardCounts.reduce((map, entry) => { - map[entry._id] = entry.count; + const apMap = accessPoints.reduce((map, ap) => { + const key = ap.accessClient.toString(); + if (!map[key]) map[key] = 0; + map[key] += ap.linkedBoardsIds?.length || 0; return map; }, {}); const now = new Date(); const result = clients.map(client => { - const boardCount = boardCountMap[client.code] || 0; + const boardCount = apMap[client._id.toString()] || 0; const daysUntilExpiry = Math.ceil( (client.subscriptionEnd - now) / (1000 * 60 * 60 * 24) ); @@ -268,31 +311,28 @@ async function listAccessClients(req, res) { } /** - * PUT /api/admin/access-clients/:code + * PUT /admin/access-clients/:slug * Updates an Access client. - * Allowed fields: isActive, isListedInApp, subscription dates, branding. + * Allowed fields: isActive, subscription dates, branding, client name/contact. */ async function updateAccessClient(req, res) { - const code = req.swagger.params.code.value.toUpperCase(); + const slug = req.swagger.params.slug.value; const updates = req.body; try { - const client = await AccessClient.findOne({ code }); + const client = await AccessClient.findOne({ slug }); if (!client) { return res.status(404).json({ message: 'Client not found' }); } - // Updatable fields if (updates.isActive !== undefined) client.isActive = updates.isActive; - if (updates.isListedInApp !== undefined) - client.isListedInApp = updates.isListedInApp; if (updates.subscriptionStart) client.subscriptionStart = new Date(updates.subscriptionStart); if (updates.subscriptionEnd) client.subscriptionEnd = new Date(updates.subscriptionEnd); - if (updates.clientName) client.clientName = updates.clientName; - if (updates.clientContact) client.clientContact = updates.clientContact; + if (updates.clientName) client.client.name = updates.clientName; + if (updates.clientContact) client.client.contact = updates.clientContact; if (updates.brandColor) client.brandColor = updates.brandColor; await client.save(); @@ -307,23 +347,35 @@ async function updateAccessClient(req, res) { } /** - * GET /api/admin/access-clients/:code/stats + * GET /admin/access-clients/:slug/stats * Gets detailed statistics for an Access client. - * Returns client info, access counts, board list with tile counts. + * Returns client info, access counts (from AccessPoints), board list with tile counts. */ async function getAccessClientStats(req, res) { - const code = req.swagger.params.code.value.toUpperCase(); + const slug = req.swagger.params.slug.value; try { - const client = await AccessClient.findOne({ code }) - .populate('rootBoardId', 'name tiles') + const client = await AccessClient.findOne({ slug }) .populate('createdBy', 'name email'); if (!client) { return res.status(404).json({ message: 'Client not found' }); } - const boards = await Board.find({ accessCode: code }, { name: 1, tiles: 1 }); + const accessPoints = await AccessPoint.find({ accessClient: client._id }); + + // Collect unique board IDs across all access points + const allBoardIds = [ + ...new Set(accessPoints.flatMap(ap => ap.linkedBoardsIds.map(id => id.toString()))) + ]; + const boards = await Board.find({ _id: { $in: allBoardIds } }, { name: 1, tiles: 1 }); + + const totalAccesses = accessPoints.reduce((sum, ap) => sum + (ap.viewsCount || 0), 0); + const lastAccessAt = accessPoints.reduce((latest, ap) => { + if (!ap.lastAccessAt) return latest; + if (!latest || ap.lastAccessAt > latest) return ap.lastAccessAt; + return latest; + }, null); const now = new Date(); const daysUntilExpiry = Math.ceil( @@ -333,8 +385,8 @@ async function getAccessClientStats(req, res) { return res.status(200).json({ client: client.toJSON(), stats: { - totalAccesses: client.accessCount, - lastAccessAt: client.lastAccessAt, + totalAccesses, + lastAccessAt, boardCount: boards.length, boards: boards.map(b => ({ id: b._id, @@ -352,3 +404,49 @@ async function getAccessClientStats(req, res) { }); } } + +/** + * PUT /admin/access-points/:code + * Re-runs board discovery for an access point, updating its linkedBoardsIds. + * Optionally accepts a new rootBoardId to change the root and re-discover from there. + * Useful when the board structure has changed since the access point was created. + */ +async function updateAccessPoint(req, res) { + const code = req.swagger.params.code.value.toUpperCase(); + const { rootBoardId } = req.body; + + try { + const accessPoint = await AccessPoint.findOne({ code }); + if (!accessPoint) { + return res.status(404).json({ message: 'Access point not found' }); + } + + const newRootBoardId = rootBoardId || accessPoint.rootBoardId; + + // Verify root board exists + const rootBoard = await Board.findById(newRootBoardId); + if (!rootBoard) { + return res.status(404).json({ message: 'Root board not found' }); + } + + // Re-discover all boards reachable from root + const linkedBoardIds = await getAllLinkedBoardIds(newRootBoardId); + + accessPoint.rootBoardId = newRootBoardId; + accessPoint.linkedBoardsIds = linkedBoardIds; + await accessPoint.save(); + + // Mark all discovered boards with this access point code + await Board.updateMany( + { _id: { $in: linkedBoardIds } }, + { $set: { accessPointCode: code } } + ); + + return res.status(200).json(accessPoint.toJSON()); + } catch (err) { + return res.status(500).json({ + message: 'Error updating access point', + error: err.message + }); + } +} From 8465d703116c80e6b52e112bd02ae3fd3764952f Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 16:41:10 -0300 Subject: [PATCH 037/113] Refactor access.js tests to adapt to AccessClient and AccessPoint models --- test/controllers/access.js | 210 ++++++++++++++++++++++++------------- 1 file changed, 136 insertions(+), 74 deletions(-) diff --git a/test/controllers/access.js b/test/controllers/access.js index a5b0f0da..d7ef45ba 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -9,6 +9,7 @@ const should = chai.should(); const helper = require('../helper'); const AccessClient = require('../../api/models/AccessClient'); +const AccessPoint = require('../../api/models/AccessPoint'); const Board = require('../../api/models/Board'); //Parent block @@ -46,19 +47,23 @@ describe('Access API calls', function () { helper.prepareNodemailerMock(true); //disable mockery await helper.deleteMochaUsers(); await Board.deleteMany({ author: 'cboard mocha test' }); - await AccessClient.deleteMany({ clientName: /mocha test/i }); + const clients = await AccessClient.find({ 'client.name': /mocha test/i }); + const clientIds = clients.map(c => c._id); + await AccessPoint.deleteMany({ accessClient: { $in: clientIds } }); + await AccessClient.deleteMany({ 'client.name': /mocha test/i }); }); describe('POST /admin/access-clients', function () { - it('it should CREATE an access client with rootBoard only', async function () { + it('it should CREATE an access client and auto-discover linked boards', async function () { const clientData = { - code: 'TEST01', + slug: 'test-01', clientName: 'Test Client mocha test', clientContact: 'test@example.com', brandColor: '#FF5733', rootBoardId: testBoardId, + accessPointCode: 'TEST01', subscriptionStart: new Date(), - subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year from now + subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; const res = await request(server) @@ -70,71 +75,55 @@ describe('Access API calls', function () { .expect(201); res.body.should.be.a('object'); - res.body.should.have.property('code').eql('TEST01'); - res.body.should.have.property('clientName').eql('Test Client mocha test'); + res.body.should.have.property('slug').eql('test-01'); + res.body.client.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('accessPoint'); + res.body.accessPoint.should.have.property('code').eql('TEST01'); + // rootBoard has no tile links, so discovery returns just the root + res.body.accessPoint.linkedBoardsIds.length.should.eql(1); - // Verify board was updated with accessCode + // Verify board was marked with accessPointCode const board = await Board.findById(testBoardId); - board.accessCode.should.eql('TEST01'); + board.accessPointCode.should.eql('TEST01'); }); - it('it should CREATE an access client with multiple boards', async function () { + it('it should NOT CREATE with duplicate slug', async function () { const clientData = { - code: 'TEST02', - clientName: 'Test Client 2 mocha test', + slug: 'duplicate', + clientName: 'Test Client mocha test', rootBoardId: testBoardId, + accessPointCode: 'DUPL01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), - boardIds: [testBoardId, testBoardId2], }; - const res = await request(server) + // Create first client + 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.have.property('code').eql('TEST02'); - - // Verify both boards were updated - const board1 = await Board.findById(testBoardId); - const board2 = await Board.findById(testBoardId2); - board1.accessCode.should.eql('TEST02'); - board2.accessCode.should.eql('TEST02'); - }); - - it('it should include rootBoard even when boardIds is empty array', async function () { - const clientData = { - code: 'TEST03', - clientName: 'Test Client 3 mocha test', - rootBoardId: testBoardId, - subscriptionStart: new Date(), - subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), - boardIds: [], // Empty array - }; - + // Try to create with same slug const res = await request(server) .post('/admin/access-clients') - .send(clientData) + .send({ ...clientData, accessPointCode: 'DUPL02' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) - .expect(201); + .expect(409); - // Verify rootBoard was updated despite empty boardIds - const board = await Board.findById(testBoardId); - board.accessCode.should.eql('TEST03'); + res.body.should.have.property('message').eql('Slug already exists'); }); - it('it should NOT CREATE with duplicate code', async function () { + it('it should NOT CREATE with duplicate access point code', async function () { const clientData = { - code: 'DUPLICATE', + slug: 'duplicate-code-a', clientName: 'Test Client mocha test', rootBoardId: testBoardId, + accessPointCode: 'DUPCODE', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -146,10 +135,10 @@ describe('Access API calls', function () { .set('Authorization', `Bearer ${adminUser.token}`) .expect(201); - // Try to create duplicate + // Try to create with same accessPointCode but different slug const res = await request(server) .post('/admin/access-clients') - .send(clientData) + .send({ ...clientData, slug: 'duplicate-code-b' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -160,9 +149,10 @@ describe('Access API calls', function () { it('it should NOT CREATE with non-existent rootBoardId', async function () { const clientData = { - code: 'TEST04', + slug: 'test-04', clientName: 'Test Client mocha test', rootBoardId: '507f1f77bcf86cd799439011', // Non-existent ID + accessPointCode: 'TEST04', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -180,9 +170,10 @@ describe('Access API calls', function () { it('it should NOT CREATE without authorization', async function () { const clientData = { - code: 'TEST05', + slug: 'test-05', clientName: 'Test Client mocha test', rootBoardId: testBoardId, + accessPointCode: 'TEST05', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -202,21 +193,22 @@ describe('Access API calls', function () { await request(server) .post('/admin/access-clients') .send({ - code: 'LIST01', + slug: 'list-01', clientName: 'List Test 1 mocha test', rootBoardId: testBoardId, + accessPointCode: 'LIST01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days - boardIds: [testBoardId, testBoardId2], }) .set('Authorization', `Bearer ${adminUser.token}`); await request(server) .post('/admin/access-clients') .send({ - code: 'LIST02', + slug: 'list-02', clientName: 'List Test 2 mocha test', rootBoardId: testBoardId, + accessPointCode: 'LIST02', subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), // Expired }) @@ -236,15 +228,15 @@ describe('Access API calls', function () { res.body.should.have.property('data').that.is.an('array'); res.body.data.length.should.be.at.least(2); - // Check first client - const client1 = res.body.data.find((c) => c.code === 'LIST01'); - client1.should.have.property('boardCount').eql(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.code === 'LIST02'); + 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 }); @@ -258,14 +250,15 @@ describe('Access API calls', function () { }); }); - describe('PUT /admin/access-clients/:code', function () { + describe('PUT /admin/access-clients/:slug', function () { beforeEach(async function () { await request(server) .post('/admin/access-clients') .send({ - code: 'UPDATE01', + slug: 'update-01', clientName: 'Update Test mocha test', rootBoardId: testBoardId, + accessPointCode: 'UPDATE01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), brandColor: '#000000', @@ -281,14 +274,14 @@ describe('Access API calls', function () { }; const res = await request(server) - .put('/admin/access-clients/UPDATE01') + .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.should.have.property('clientName').eql('Updated Name mocha test'); + res.body.client.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); }); @@ -300,7 +293,7 @@ describe('Access API calls', function () { }; const res = await request(server) - .put('/admin/access-clients/UPDATE01') + .put('/admin/access-clients/update-01') .send(updates) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -311,9 +304,9 @@ describe('Access API calls', function () { actualDate.getTime().should.be.closeTo(newEndDate.getTime(), 1000); }); - it('it should return 404 for non-existent code', async function () { + it('it should return 404 for non-existent slug', async function () { const res = await request(server) - .put('/admin/access-clients/NONEXISTENT') + .put('/admin/access-clients/nonexistent') .send({ clientName: 'Test' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -325,7 +318,7 @@ describe('Access API calls', function () { it('it should NOT UPDATE without authorization', async function () { await request(server) - .put('/admin/access-clients/UPDATE01') + .put('/admin/access-clients/update-01') .send({ clientName: 'Test' }) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -333,24 +326,92 @@ describe('Access API calls', function () { }); }); - describe('GET /admin/access-clients/:code/stats', function () { + describe('PUT /admin/access-points/:code', function () { + beforeEach(async function () { + await request(server) + .post('/admin/access-clients') + .send({ + slug: 'ap-update-01', + clientName: 'Access Point Update Test mocha test', + rootBoardId: testBoardId, + accessPointCode: '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 point root', async function () { + const res = await request(server) + .put('/admin/access-points/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('linkedBoardsIds').that.is.an('array'); + res.body.linkedBoardsIds.should.include(testBoardId2.toString()); + + // Verify new root board was marked with accessPointCode + const board = await Board.findById(testBoardId2); + board.accessPointCode.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-points/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.linkedBoardsIds.should.include(testBoardId.toString()); + }); + + it('it should return 404 for non-existent access point code', async function () { + const res = await request(server) + .put('/admin/access-points/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 point not found'); + }); + + it('it should NOT UPDATE without authorization', async function () { + await request(server) + .put('/admin/access-points/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({ - code: 'STATS01', + slug: 'stats-01', clientName: 'Stats Test mocha test', rootBoardId: testBoardId, + accessPointCode: 'STATS01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), - boardIds: [testBoardId, testBoardId2], }) .set('Authorization', `Bearer ${adminUser.token}`); }); it('it should GET client statistics', async function () { const res = await request(server) - .get('/admin/access-clients/STATS01/stats') + .get('/admin/access-clients/stats-01/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -361,14 +422,14 @@ describe('Access API calls', function () { res.body.should.have.property('stats'); // Verify client data - res.body.client.should.have.property('code').eql('STATS01'); - res.body.client.should.have.property('clientName').eql('Stats Test mocha test'); + res.body.client.should.have.property('slug').eql('stats-01'); + res.body.client.client.should.have.property('name').eql('Stats Test mocha test'); - // Verify stats + // 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(2); + 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(2); + 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); @@ -384,16 +445,17 @@ describe('Access API calls', function () { await request(server) .post('/admin/access-clients') .send({ - code: 'EXPIRED01', + slug: 'expired-01', clientName: 'Expired Test mocha test', rootBoardId: testBoardId, + accessPointCode: '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/EXPIRED01/stats') + .get('/admin/access-clients/expired-01/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -403,9 +465,9 @@ describe('Access API calls', function () { res.body.stats.should.have.property('daysUntilExpiry').eql(0); // Clamped at 0 }); - it('it should return 404 for non-existent code', async function () { + it('it should return 404 for non-existent slug', async function () { const res = await request(server) - .get('/admin/access-clients/NONEXISTENT/stats') + .get('/admin/access-clients/nonexistent/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -416,7 +478,7 @@ describe('Access API calls', function () { it('it should NOT GET stats without authorization', async function () { await request(server) - .get('/admin/access-clients/STATS01/stats') + .get('/admin/access-clients/stats-01/stats') .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(403); From b1f2c277001d39c569e857fe58e7598ce24118b4 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 19:55:47 -0300 Subject: [PATCH 038/113] Fix now is accesPointCode --- api/controllers/board.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 66ade313..f48ce9ac 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -80,7 +80,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true, accessCode: null } }, + { query: { ...query, isPublic: true, accessPointCode: null } }, req.query ); @@ -127,12 +127,12 @@ function getBoard(req, res) { }); } - // If the board has accessCode, block direct access - if (boards.accessCode) { + // If the board has accessPointCode, block direct access + if (boards.accessPointCode) { return res.status(403).json({ message: 'This board requires an access code', requiresAccessCode: true, - accessCode: boards.accessCode + accessPointCode: boards.accessPointCode }); } From 8662488de5158512940d70fc1cd33e1f829410d7 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:13:08 -0300 Subject: [PATCH 039/113] Clean up AccessPoint references when a board is deleted --- api/controllers/board.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index f48ce9ac..9ddc496f 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -4,6 +4,7 @@ const moment = require('moment'); const { paginatedResponse } = require('../helpers/response'); const { getORQuery } = require('../helpers/query'); const Board = require('../models/Board'); +const AccessPoint = require('../models/AccessPoint'); const { getCbuilderBoardbyId } = require('../helpers/cbuilder'); const { processBase64Images, hasBase64Images } = require('../helpers/imageProcessor'); @@ -89,7 +90,7 @@ async function getPublicBoards(req, res) { async function deleteBoard(req, res) { const id = req.swagger.params.id.value; - Board.findByIdAndRemove(id, function (err, boards) { + Board.findByIdAndRemove(id, async function (err, boards) { if (err) { return res.status(404).json({ message: 'Board not found. Board Id: ' + id, @@ -102,6 +103,14 @@ async function deleteBoard(req, res) { error: 'Board not found.' }); } + await AccessPoint.updateMany( + { linkedBoardsIds: id }, + { $pull: { linkedBoardsIds: id } } + ); + await AccessPoint.updateMany( + { rootBoardId: id }, + { $set: { rootBoardId: null } } + ); return res.status(200).json(boards); }); } From d925ceacd16ff0afa8bb1542f38cad69dd3bc95a Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:19:09 -0300 Subject: [PATCH 040/113] Admins are able to access the board directly --- api/controllers/board.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 9ddc496f..c9634624 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -136,8 +136,8 @@ function getBoard(req, res) { }); } - // If the board has accessPointCode, block direct access - if (boards.accessPointCode) { + // If the board has accessPointCode, block direct access (admins bypass this) + if (boards.accessPointCode && !(req.user && req.user.isAdmin)) { return res.status(403).json({ message: 'This board requires an access code', requiresAccessCode: true, From 623dd61a2f21d5b193b04e4ee8ff9e35addfcc31 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:19:28 -0300 Subject: [PATCH 041/113] Added tests to support admin accessing directly to the boards --- test/controllers/board.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index 8e84e274..5801d45b 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -336,7 +336,7 @@ describe('Board API calls', function () { const boardId = createRes.body.id; - // Direct access should be blocked with 403 + // Direct access should be blocked with 403 for regular users const getRes = await request(server) .get('/board/' + boardId) .set('Authorization', `Bearer ${user.token}`) @@ -350,6 +350,19 @@ describe('Board API calls', function () { getRes.body.requiresAccessCode.should.equal(true); getRes.body.should.have.property('accessPointCode'); getRes.body.accessPointCode.should.equal('GETTEST01'); + + // Admins should be able to access the board directly + const adminEmail = helper.generateEmail(); + const admin = await helper.prepareUser(server, { role: 'admin', email: adminEmail }); + const adminRes = await request(server) + .get('/board/' + boardId) + .set('Authorization', `Bearer ${admin.token}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200); + + adminRes.body.should.have.property('accessPointCode'); + adminRes.body.accessPointCode.should.equal('GETTEST01'); }); it('should exclude boards with accessCode from public boards listing', async function () { From b3475b20f119b60934823d26174d186c286660a3 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:34:42 -0300 Subject: [PATCH 042/113] Refactored swagger to adapt to the new AccessClient and AccessPoint models --- api/swagger/swagger.yaml | 209 ++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 78 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 4e29ab6c..41befdd0 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1268,7 +1268,7 @@ paths: x-swagger-router-controller: access get: operationId: getClients - description: Lists active companies for Cboard Access listing in the app. Returns clients where isActive=true, isListedInApp=true, and subscription is valid. + description: Lists active companies for Cboard Access. Returns clients where isActive=true and subscription is valid. Sorted by client name. responses: "200": description: Success @@ -1306,7 +1306,7 @@ paths: x-swagger-router-controller: access post: operationId: createAccessClient - description: Creates a new Access client. Requires admin authentication. Creates AccessClient and updates boards with accessCode. + description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessPointCode. security: - Bearer: [] x-security-scopes: @@ -1351,21 +1351,21 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-clients/{code}: + /admin/access-clients/{slug}: x-swagger-router-controller: access put: operationId: updateAccessClient - description: Updates an Access client. Requires admin authentication. Allowed fields include isActive, isListedInApp, subscription dates, and branding. + description: Updates an Access client. Requires admin authentication. Allowed fields include isActive, subscription dates, and branding. security: - Bearer: [] x-security-scopes: - admin parameters: - - name: code + - name: slug type: string in: path required: true - description: Access code for the client + description: Slug identifier for the client - name: updates in: body description: Fields to update @@ -1385,7 +1385,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-clients/{code}/stats: + /admin/access-clients/{slug}/stats: x-swagger-router-controller: access get: operationId: getAccessClientStats @@ -1395,11 +1395,11 @@ paths: x-security-scopes: - admin parameters: - - name: code + - name: slug type: string in: path required: true - description: Access code for the client + description: Slug identifier for the client responses: "200": description: Success @@ -1441,6 +1441,43 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" + /admin/access-points/{code}: + x-swagger-router-controller: access + put: + operationId: updateAccessPoint + description: Re-runs board discovery for an access point, updating its linkedBoardsIds. Optionally accepts a new rootBoardId. Requires admin authentication. + security: + - Bearer: [] + x-security-scopes: + - admin + parameters: + - name: code + type: string + in: path + required: true + description: Access point 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/AccessPointResponse" + "404": + description: Access point or root board not found + schema: + $ref: "#/definitions/ErrorResponse" + default: + description: Error + schema: + $ref: "#/definitions/ErrorResponse" # complex objects have schema definitions definitions: AnalyticsReport: @@ -2126,15 +2163,20 @@ definitions: AccessClientCreate: type: object required: - - code + - slug - clientName - rootBoardId - subscriptionStart - subscriptionEnd + - accessPointCode properties: - code: + slug: + type: string + description: Unique slug identifier for the access client (lowercase) + example: my-company + accessPointCode: type: string - description: Unique code for the access client (uppercase) + description: Unique code for the access point (will be uppercased) example: ABC123 clientName: type: string @@ -2149,7 +2191,7 @@ definitions: example: "#FF5733" rootBoardId: type: string - description: ID of the root board + description: ID of the root board (boards reachable from it are auto-discovered) subscriptionStart: type: string format: date-time @@ -2158,20 +2200,12 @@ definitions: type: string format: date-time description: Subscription end date - boardIds: - type: array - items: - type: string - description: Array of board IDs to associate with this access code AccessClientUpdate: type: object properties: isActive: type: boolean description: Whether the client is active - isListedInApp: - type: boolean - description: Whether the client is listed in the app subscriptionStart: type: string format: date-time @@ -2195,27 +2229,24 @@ definitions: id: type: string description: Client ID - code: - type: string - description: Access code - clientName: - type: string - description: Client name - clientContact: + slug: type: string - description: Client contact information + description: Unique slug identifier (lowercase) + client: + type: object + properties: + name: + type: string + description: Client organization name + contact: + type: string + description: Client contact information brandColor: type: string description: Brand color (hex) - rootBoardId: - type: string - description: Root board ID isActive: type: boolean description: Whether client is active - isListedInApp: - type: boolean - description: Whether client is listed in app subscriptionStart: type: string format: date-time @@ -2224,13 +2255,6 @@ definitions: type: string format: date-time description: Subscription end date - accessCount: - type: integer - description: Number of times accessed - lastAccessAt: - type: string - format: date-time - description: Last access timestamp createdBy: type: string description: ID of user who created the client @@ -2242,6 +2266,9 @@ definitions: type: string format: date-time description: Last update timestamp + accessPoint: + description: Present only on create response + $ref: "#/definitions/AccessPointResponse" AccessClientsListResponse: type: object required: @@ -2256,26 +2283,34 @@ definitions: items: type: object properties: - code: + slug: type: string - description: Access code + description: Client slug identifier clientName: type: string description: Client name brandColor: type: string description: Brand color (hex) - rootBoard: - type: object - properties: - id: - type: string - name: - type: string - caption: - type: string - tilesCount: - type: integer + accessPoints: + type: array + items: + type: object + properties: + code: + type: string + description: Access point code + rootBoard: + type: object + properties: + id: + type: string + name: + type: string + caption: + type: string + tilesCount: + type: integer AccessClientsAdminListResponse: type: object required: @@ -2292,43 +2327,29 @@ definitions: properties: id: type: string - code: - type: string - clientName: - type: string - clientContact: + slug: type: string - brandColor: - type: string - rootBoardId: + client: type: object - description: Populated root board object properties: - _id: - type: string name: type: string + contact: + type: string + brandColor: + type: string isActive: type: boolean - isListedInApp: - type: boolean subscriptionStart: type: string format: date-time subscriptionEnd: type: string format: date-time - accessCount: - type: integer - lastAccessAt: - type: string - format: date-time createdBy: type: object description: Populated user object who created the client properties: - _id: - type: string name: type: string email: @@ -2341,7 +2362,7 @@ definitions: format: date-time boardCount: type: integer - description: Number of boards associated with this client + description: Total boards across all access points for this client isExpired: type: boolean description: Whether the subscription has expired @@ -2406,6 +2427,38 @@ definitions: type: boolean grid: type: object - accessCode: + accessPointCode: + type: string + description: The access point code associated with this board + AccessPointResponse: + type: object + properties: + id: + type: string + code: + type: string + description: Access point code (uppercase) + accessClient: + type: string + description: AccessClient ID + rootBoardId: + type: string + description: Root board ID + linkedBoardsIds: + type: array + items: + type: string + description: All board IDs reachable from the root board + viewsCount: + type: integer + description: Number of times this access point has been accessed + lastAccessAt: type: string - description: The access code associated with this board + format: date-time + description: Last access timestamp + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time From bda95229a53ec5c6e28f01074a8fc17974f96010 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:41:45 -0300 Subject: [PATCH 043/113] Added User and getTokenData imports. In getBoard, replaced the broken await-based check with a Promise .then() chain --- api/controllers/board.js | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index c9634624..85224cf7 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -5,6 +5,8 @@ const { paginatedResponse } = require('../helpers/response'); const { getORQuery } = require('../helpers/query'); const Board = require('../models/Board'); const AccessPoint = require('../models/AccessPoint'); +const User = require('../models/User'); +const { getTokenData } = require('../helpers/auth'); const { getCbuilderBoardbyId } = require('../helpers/cbuilder'); const { processBase64Images, hasBase64Images } = require('../helpers/imageProcessor'); @@ -136,16 +138,32 @@ function getBoard(req, res) { }); } - // If the board has accessPointCode, block direct access (admins bypass this) - if (boards.accessPointCode && !(req.user && req.user.isAdmin)) { - return res.status(403).json({ - message: 'This board requires an access code', - requiresAccessCode: true, - accessPointCode: boards.accessPointCode - }); + // If no accessPointCode, serve normally + if (!boards.accessPointCode) { + return res.status(200).json(boards.toJSON()); + } + + // Board is access-protected. Check if caller is admin by decoding the token manually — + // req.user is not populated for this public route. + let adminCheckPromise = Promise.resolve(false); + const authHeader = req.get('Authorization'); + if (authHeader) { + const tokenData = getTokenData(authHeader.split(' ')[1]); + if (tokenData?.id) { + adminCheckPromise = User.getById(tokenData.id).then(user => !!(user && user.isAdmin)); + } } - return res.status(200).json(boards.toJSON()); + adminCheckPromise.then(isAdmin => { + if (!isAdmin) { + return res.status(403).json({ + message: 'This board requires an access code', + requiresAccessCode: true, + accessPointCode: boards.accessPointCode + }); + } + return res.status(200).json(boards.toJSON()); + }); }); } From db1306df919998c06cb728bb37ec9c3239dda712 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 9 Apr 2026 20:41:57 -0300 Subject: [PATCH 044/113] Fixed null.should --- test/controllers/board.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index 5801d45b..d8eaca23 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -299,7 +299,7 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.accessPointCode.should.equal(null); + expect(res.body.accessPointCode).to.be.null; }); it('should update accessPointCode on an existing board', async function () { From 4c6683464742cc268a4c560ca558a63860cf00ae Mon Sep 17 00:00:00 2001 From: Agus Date: Fri, 10 Apr 2026 13:16:26 -0300 Subject: [PATCH 045/113] Refactor postman collection to adapt to new AccessClient and AccessPoint models --- test/postman/CboardAccess.collection.json | 125 +++++++++++++++++----- 1 file changed, 96 insertions(+), 29 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index e028f489..c24e19dc 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -23,23 +23,27 @@ "", "pm.test(\"Response contains client data\", function () {", " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('code');", - " pm.expect(jsonData).to.have.property('clientName');", - " pm.expect(jsonData).to.have.property('rootBoardId');", + " pm.expect(jsonData).to.have.property('slug');", + " pm.expect(jsonData).to.have.property('client');", + " pm.expect(jsonData.client).to.have.property('name');", "});", "", - "pm.test(\"Response contains auto-discovered boards\", function () {", + "pm.test(\"Response contains access point with auto-discovered boards\", function () {", " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('linkedBoardsCount');", - " pm.expect(jsonData).to.have.property('linkedBoardIds');", - " pm.expect(jsonData.linkedBoardsCount).to.be.at.least(1);", - " console.log(\"Auto-discovered \" + jsonData.linkedBoardsCount + \" boards\");", + " pm.expect(jsonData).to.have.property('accessPoint');", + " pm.expect(jsonData.accessPoint).to.have.property('code');", + " pm.expect(jsonData.accessPoint).to.have.property('linkedBoardsIds');", + " pm.expect(jsonData.accessPoint.linkedBoardsIds.length).to.be.at.least(1);", + " console.log(\"Auto-discovered \" + jsonData.accessPoint.linkedBoardsIds.length + \" boards\");", "});", "", - "// Save the code for later requests", + "// Save the slug and access point code for later requests", "if (pm.response.code === 201) {", - " pm.collectionVariables.set(\"access_code\", pm.response.json().code);", - " console.log(\"Access code saved: \" + pm.response.json().code);", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set(\"client_slug\", jsonData.slug);", + " pm.collectionVariables.set(\"access_code\", jsonData.accessPoint.code);", + " console.log(\"Client slug saved: \" + jsonData.slug);", + " console.log(\"Access code saved: \" + jsonData.accessPoint.code);", "}" ], "type": "text/javascript" @@ -61,7 +65,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"code\": \"TEST01\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" + "raw": "{\n \"slug\": \"test-coffee-shop\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"accessPointCode\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" }, "url": { "raw": "{{url}}/admin/access-clients", @@ -99,8 +103,9 @@ " var jsonData = pm.response.json();", " if (jsonData.data.length > 0) {", " var client = jsonData.data[0];", - " pm.expect(client).to.have.property('code');", - " pm.expect(client).to.have.property('clientName');", + " pm.expect(client).to.have.property('slug');", + " pm.expect(client).to.have.property('client');", + " pm.expect(client.client).to.have.property('name');", " pm.expect(client).to.have.property('boardCount');", " pm.expect(client).to.have.property('isExpired');", " }", @@ -146,7 +151,7 @@ "", "pm.test(\"Client was updated\", function () {", " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('code');", + " pm.expect(jsonData).to.have.property('slug');", "});" ], "type": "text/javascript" @@ -168,17 +173,17 @@ ], "body": { "mode": "raw", - "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientContact\": \"newemail@testcoffee.com\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true,\n \"isListedInApp\": true\n}" + "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientContact\": \"newemail@testcoffee.com\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true\n}" }, "url": { - "raw": "{{url}}/admin/access-clients/{{access_code}}", + "raw": "{{url}}/admin/access-clients/{{client_slug}}", "host": [ "{{url}}" ], "path": [ "admin", "access-clients", - "{{access_code}}" + "{{client_slug}}" ] }, "description": "Updates an existing Cboard Access client. Requires admin authentication." @@ -223,14 +228,14 @@ "raw": "{\n \"isActive\": false\n}" }, "url": { - "raw": "{{url}}/admin/access-clients/{{access_code}}", + "raw": "{{url}}/admin/access-clients/{{client_slug}}", "host": [ "{{url}}" ], "path": [ "admin", "access-clients", - "{{access_code}}" + "{{client_slug}}" ] }, "description": "Deactivates a Cboard Access client by setting isActive to false. Requires admin authentication." @@ -277,20 +282,74 @@ } ], "url": { - "raw": "{{url}}/admin/access-clients/{{access_code}}/stats", + "raw": "{{url}}/admin/access-clients/{{client_slug}}/stats", "host": [ "{{url}}" ], "path": [ "admin", "access-clients", - "{{access_code}}", + "{{client_slug}}", "stats" ] }, "description": "Retrieves detailed statistics for a specific Cboard Access client. Requires admin authentication." }, "response": [] + }, + { + "name": "Update Access Point", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response contains access point data\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('code');", + " pm.expect(jsonData).to.have.property('linkedBoardsIds');", + " 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-points/{{access_code}}", + "host": [ + "{{url}}" + ], + "path": [ + "admin", + "access-points", + "{{access_code}}" + ] + }, + "description": "Re-runs board discovery for an access point. Optionally pass a new rootBoardId to change the root and re-discover from there. Useful when the board structure has changed." + }, + "response": [] } ] }, @@ -316,20 +375,23 @@ " pm.expect(jsonData.data).to.be.an('array');", "});", "", - "pm.test(\"Only active and listed clients are returned\", function () {", + "pm.test(\"Clients have required fields\", function () {", " var clients = pm.response.json().data;", " clients.forEach(function(client) {", - " pm.expect(client).to.have.property('code');", + " 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('rootBoard');", + " pm.expect(client).to.have.property('accessPoints');", + " pm.expect(client.accessPoints).to.be.an('array');", " });", "});", "", - "// Save first code for testing board access", - "if (pm.response.json().data.length > 0) {", - " pm.collectionVariables.set(\"access_code\", pm.response.json().data[0].code);", - " console.log(\"Access code saved: \" + pm.response.json().data[0].code);", + "// Save first access point code for testing board access", + "var data = pm.response.json().data;", + "if (data.length > 0 && data[0].accessPoints.length > 0) {", + " var code = data[0].accessPoints[0].code;", + " pm.collectionVariables.set(\"access_code\", code);", + " console.log(\"Access code saved: \" + code);", "}" ], "type": "text/javascript" @@ -465,6 +527,11 @@ "value": "http://localhost:10010", "type": "string" }, + { + "key": "client_slug", + "value": "test-coffee-shop", + "type": "string" + }, { "key": "access_code", "value": "TEST01", From 1e9ddb3145d5f802b9f95bc343bc99985d62234d Mon Sep 17 00:00:00 2001 From: Agus Date: Fri, 10 Apr 2026 13:16:49 -0300 Subject: [PATCH 046/113] Refactor README.md to accomplish the new changes --- test/postman/CboardAccess.README.md | 85 ++++++++++++----------------- 1 file changed, 34 insertions(+), 51 deletions(-) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 7c7f89d6..97939c1e 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -12,11 +12,12 @@ Cboard Access allows businesses to offer AAC boards in their locations via QR co - **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 +- **Create Access Client**: Create a new Cboard Access client and its first access point - **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 Point**: Re-run board discovery for an access point ### 3. Public Endpoints (No Authentication) - **Test Public Client Listing**: List active clients for app display @@ -52,37 +53,10 @@ The collection includes these variables (can be edited in collection variables o |----------|---------------|-------------| | `url` | `http://localhost:10010` | Base API URL | | `token` | (set automatically) | Authentication token for admin endpoints (saved to globals on login) | -| `access_code` | `TEST01` | Access code for testing | +| `client_slug` | `test-coffee-shop` | Client slug for admin endpoints (set automatically on create) | +| `access_code` | `TEST01` | Access point code for public board access (set automatically on create) | | `root_board_id` | (empty) | Board ID to use as root board | -### 3. Optional: Create Environment - -You can create a custom environment for different deployment targets: - -**Local Development:** -```json -{ - "API_URL": "http://localhost:10010", - "root_board_id": "your-board-id-here" -} -``` - -**QA Environment:** -```json -{ - "API_URL": "https://api-qa.cboard.io", - "root_board_id": "qa-board-id" -} -``` - -**Production:** -```json -{ - "API_URL": "https://api.cboard.io", - "root_board_id": "production-board-id" -} -``` - ## Usage Workflow ### First Time Setup @@ -95,54 +69,60 @@ You can create a custom environment for different deployment targets: 2. **Authenticate** - Update credentials in "Login to Get Auth Token" request - Run the request - - The `AUTH_TOKEN` variable will be automatically set from the response + - The `token` variable will be automatically set from the response ### Creating a Client 1. Run **Create Access Client** with your desired configuration: ```json { - "code": "CAFE01", + "slug": "coffee-shop-downtown", "clientName": "Coffee Shop Downtown", "clientContact": "manager@coffee.com", "brandColor": "#8B4513", "rootBoardId": "{{root_board_id}}", + "accessPointCode": "CAFE01", "subscriptionStart": "2026-04-01T00:00:00.000Z", - "subscriptionEnd": "2027-04-01T00:00:00.000Z", - "boardIds": ["{{root_board_id}}"] + "subscriptionEnd": "2027-04-01T00:00:00.000Z" } ``` -2. The response will include the created client -3. The `access_code` variable will be automatically updated +2. The response includes the created client (`slug`, `client.name`, etc.) and the `accessPoint` with `code` and `linkedBoardsIds` +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 the client with statistics +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 +- Use **Update Client** to modify properties (uses `{{client_slug}}`) - Use **Deactivate Client** to disable access without deleting +### Re-discovering Boards + +Use **Update Access Point** 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:** -- `code`: Unique identifier (uppercase, alphanumeric) +- `slug`: Unique client identifier (lowercase, URL-safe) - `clientName`: Display name - `rootBoardId`: ID of the main board +- `accessPointCode`: Unique code for the access point (uppercase alphanumeric) - `subscriptionStart`: Start date (ISO 8601) - `subscriptionEnd`: End date (ISO 8601) **Optional Fields:** - `clientContact`: Contact information - `brandColor`: Hex color code -- `boardIds`: Array of board IDs to associate (if omitted, linked boards are auto-discovered from root board) + +Board discovery is automatic — all boards reachable from `rootBoardId` via tile navigation are linked to the access point. ### Update Client @@ -151,7 +131,6 @@ You can create a custom environment for different deployment targets: - `clientContact` - `brandColor` - `isActive` (boolean) -- `isListedInApp` (boolean) - `subscriptionStart` - `subscriptionEnd` @@ -159,7 +138,7 @@ You can create a custom environment for different deployment targets: These endpoints don't require authentication and simulate real user access: -- **GET /access/clients**: Returns only active, listed clients with valid subscriptions +- **GET /access/clients**: Returns only active clients with valid subscriptions - **GET /access/:code**: Returns all boards for the access code and increments access counter ## Test Scripts @@ -168,16 +147,16 @@ Each request includes automated tests: - **Status code validation**: Ensures correct HTTP responses - **Response structure validation**: Checks for required fields -- **Variable auto-setting**: Saves tokens and IDs for subsequent requests +- **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 +2. Create access client (saves `client_slug` and `access_code` automatically) 3. Verify in "List All Clients" -4. Test public access +4. Test public access via "Test Public Board Access" ### Scenario 2: Client Expiration @@ -193,9 +172,15 @@ Each request includes automated tests: 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 Point" to re-discover linked boards +3. Verify `linkedBoardsIds` count updated + ## Troubleshooting -### AUTH_TOKEN Not Being Set After Login +### 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) @@ -214,11 +199,10 @@ Each request includes automated tests: ### "Invalid access code" Error - Ensure the client is active (`isActive: true`) -- Check subscription dates are valid -- Verify `isListedInApp` is true for public listing +- Check subscription dates are valid (start ≤ now ≤ end) ### Authentication Errors -- Ensure `AUTH_TOKEN` is set (run login request) +- Ensure `token` is set (run login request) - Check token hasn't expired - Verify user has admin privileges (role: "admin") @@ -229,13 +213,12 @@ Each request includes automated tests: ## Related Documentation -- [Implementation Plan](../../CBOARD_ACCESS_IMPLEMENTATION_PLAN.md) - [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 -- Public endpoints are rate-limited (if configured) - 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 From 0f50c69434cab3ae9367ce74687ba42241b68885 Mon Sep 17 00:00:00 2001 From: Agus Date: Fri, 10 Apr 2026 13:17:57 -0300 Subject: [PATCH 047/113] Added user role in the populated user --- api/controllers/access.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 441dcb1e..c810b133 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -269,7 +269,7 @@ async function createAccessClient(req, res) { async function listAccessClients(req, res) { try { const clients = await AccessClient.find() - .populate('createdBy', 'name email') + .populate('createdBy', 'name email role') .sort({ createdAt: -1 }); // Fetch access points and sum linkedBoardsIds per client to avoid N+1 @@ -356,7 +356,7 @@ async function getAccessClientStats(req, res) { try { const client = await AccessClient.findOne({ slug }) - .populate('createdBy', 'name email'); + .populate('createdBy', 'name email role'); if (!client) { return res.status(404).json({ message: 'Client not found' }); From 2cee5501966826e5bf8f94713f07af56386753cc Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 18:49:50 -0300 Subject: [PATCH 048/113] Changed contact field to email and added phone --- api/models/AccessClient.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js index a7f84095..9f1ef178 100644 --- a/api/models/AccessClient.js +++ b/api/models/AccessClient.js @@ -17,7 +17,11 @@ const ACCESS_CLIENT_SCHEMA_DEFINITION = { required: true, trim: true }, - contact: { + email: { + type: String, + trim: true + }, + phone: { type: String, trim: true } From d279105289eafd1b1bab81b73d870c031fc3a326 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 18:53:32 -0300 Subject: [PATCH 049/113] Added uniqueness to accessPointCode and sparse to ensure null values are excluded from uniqueness --- api/models/Board.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index b6ec4da8..0b49ebb2 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -67,7 +67,9 @@ const BOARD_SCHEMA_DEFINITION = { type: String, trim: true, uppercase: true, - default: null + default: null, + unique: true, + sparse: true } }; @@ -87,8 +89,8 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse index skips documents where accessPointCode is null/missing, optimizing queries for Access boards -boardSchema.index({ accessPointCode: 1 }, { sparse: true }); +// Sparse unique index skips documents where accessPointCode is null/missing, enforcing uniqueness only for set values +boardSchema.index({ accessPointCode: 1 }, { sparse: true, unique: true }); const validatePresenceOf = value => value && value.length; From 5091e7bf75d527fec188b2f5ef6d14e09a646347 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 19:09:15 -0300 Subject: [PATCH 050/113] Updated access.js to reflect client email and phone addition --- api/controllers/access.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index c810b133..a962d051 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -199,7 +199,8 @@ async function createAccessClient(req, res) { const { slug, clientName, - clientContact, + clientEmail, + clientPhone, brandColor, rootBoardId, subscriptionStart, @@ -217,7 +218,7 @@ async function createAccessClient(req, res) { // Create the client const client = new AccessClient({ slug, - client: { name: clientName, contact: clientContact }, + client: { name: clientName, email: clientEmail, phone: clientPhone }, brandColor, subscriptionStart: new Date(subscriptionStart), subscriptionEnd: new Date(subscriptionEnd), @@ -313,7 +314,7 @@ async function listAccessClients(req, res) { /** * PUT /admin/access-clients/:slug * Updates an Access client. - * Allowed fields: isActive, subscription dates, branding, client name/contact. + * Allowed fields: isActive, subscription dates, branding, client name/email/phone. */ async function updateAccessClient(req, res) { const slug = req.swagger.params.slug.value; @@ -332,7 +333,8 @@ async function updateAccessClient(req, res) { if (updates.subscriptionEnd) client.subscriptionEnd = new Date(updates.subscriptionEnd); if (updates.clientName) client.client.name = updates.clientName; - if (updates.clientContact) client.client.contact = updates.clientContact; + if (updates.clientEmail) client.client.email = updates.clientEmail; + if (updates.clientPhone) client.client.phone = updates.clientPhone; if (updates.brandColor) client.brandColor = updates.brandColor; await client.save(); From 341ba6b1715f3d21e4707e57c9779e60d2ace159 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 19:15:15 -0300 Subject: [PATCH 051/113] Updated swagger to adapt to new client fields email and phone --- api/swagger/swagger.yaml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 41befdd0..95ecc78f 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2182,9 +2182,12 @@ definitions: type: string description: Name of the client organization example: Company Name - clientContact: + clientEmail: type: string - description: Contact information for the client + 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) @@ -2217,9 +2220,12 @@ definitions: clientName: type: string description: Name of the client organization - clientContact: + clientEmail: + type: string + description: Email of the client + clientPhone: type: string - description: Contact information for the client + description: Phone number of the client brandColor: type: string description: Brand color for the client (hex color) @@ -2238,9 +2244,12 @@ definitions: name: type: string description: Client organization name - contact: + email: type: string - description: Client contact information + description: Email of the client + phone: + type: string + description: Phone number of the client brandColor: type: string description: Brand color (hex) @@ -2334,7 +2343,9 @@ definitions: properties: name: type: string - contact: + email: + type: string + phone: type: string brandColor: type: string From 2a387d37b2d930a47b912fdd2ad50332b3477218 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 19:18:32 -0300 Subject: [PATCH 052/113] Changed collection to adapt to new client email and phone fields --- test/postman/CboardAccess.collection.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index c24e19dc..56f779f4 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -65,7 +65,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"slug\": \"test-coffee-shop\",\n \"clientName\": \"Test Coffee Shop\",\n \"clientContact\": \"contact@testcoffee.com\",\n \"brandColor\": \"#8B4513\",\n \"rootBoardId\": \"{{root_board_id}}\",\n \"accessPointCode\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" + "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 \"accessPointCode\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" }, "url": { "raw": "{{url}}/admin/access-clients", @@ -173,7 +173,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"clientName\": \"Test Coffee Shop - Updated\",\n \"clientContact\": \"newemail@testcoffee.com\",\n \"brandColor\": \"#6F4E37\",\n \"isActive\": true\n}" + "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}}", From 8eeb8fe05e776be81407c49d555a2028428687ea Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 19:21:21 -0300 Subject: [PATCH 053/113] Updated README.md to adapt to new client phone and email fields --- test/postman/CboardAccess.README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 97939c1e..570e178e 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -78,7 +78,8 @@ The collection includes these variables (can be edited in collection variables o { "slug": "coffee-shop-downtown", "clientName": "Coffee Shop Downtown", - "clientContact": "manager@coffee.com", + "clientEmail": "manager@coffee.com", + "clientPhone": "+1234567890", "brandColor": "#8B4513", "rootBoardId": "{{root_board_id}}", "accessPointCode": "CAFE01", @@ -119,7 +120,8 @@ Use **Update Access Point** to re-run board discovery after the board structure - `subscriptionEnd`: End date (ISO 8601) **Optional Fields:** -- `clientContact`: Contact information +- `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 point. @@ -128,7 +130,8 @@ Board discovery is automatic — all boards reachable from `rootBoardId` via til **Updatable Fields:** - `clientName` -- `clientContact` +- `clientEmail` +- `clientPhone` - `brandColor` - `isActive` (boolean) - `subscriptionStart` From 35f9c8942ccd5df90df686d8044c93061917dae9 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 13 Apr 2026 19:23:34 -0300 Subject: [PATCH 054/113] Removed unique:true and sparse:true from field definition to avoid duplicate index --- api/models/Board.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index 0b49ebb2..de9724c3 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -67,9 +67,7 @@ const BOARD_SCHEMA_DEFINITION = { type: String, trim: true, uppercase: true, - default: null, - unique: true, - sparse: true + default: null } }; From 628326fa2b99ebc4fa550049675b254a2750b680 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 14 Apr 2026 17:29:57 -0300 Subject: [PATCH 055/113] Changed accessPointCode to accessEntryPoint --- api/models/Board.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index 0b49ebb2..9bfce651 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -63,7 +63,7 @@ const BOARD_SCHEMA_DEFINITION = { default: false }, grid: {}, - accessPointCode: { + accessEntryPoint: { type: String, trim: true, uppercase: true, @@ -89,8 +89,8 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse unique index skips documents where accessPointCode is null/missing, enforcing uniqueness only for set values -boardSchema.index({ accessPointCode: 1 }, { sparse: true, unique: true }); +// Sparse unique index skips documents where accessEntryPoint is null/missing, enforcing uniqueness only for set values +boardSchema.index({ accessEntryPoint: 1 }, { sparse: true, unique: true }); const validatePresenceOf = value => value && value.length; From 642856bad269cb5be32e378119578b74f248156d Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 14 Apr 2026 17:30:13 -0300 Subject: [PATCH 056/113] Adapted tests to changed field accessEntryPoint --- test/controllers/board.js | 40 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index a8af8c1a..9fb27cea 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,11 +252,11 @@ describe('Board API calls', function () { }); }); - describe('accessPointCode field behavior', function () { - it('should normalize accessPointCode to uppercase when creating a board', async function () { + describe('accessEntryPoint field behavior', function () { + it('should normalize accessEntryPoint to uppercase when creating a board', async function () { const boardWithAccessCode = { ...helper.boardData, - accessPointCode: 'cafe01' + accessEntryPoint: 'cafe01' }; const res = await request(server) .post('/board') @@ -266,14 +266,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessPointCode'); - res.body.accessPointCode.should.equal('CAFE01'); + res.body.should.have.property('accessEntryPoint'); + res.body.accessEntryPoint.should.equal('CAFE01'); }); - it('should trim whitespace from accessPointCode', async function () { + it('should trim whitespace from accessEntryPoint', async function () { const boardWithAccessCode = { ...helper.boardData, - accessPointCode: ' test01 ' + accessEntryPoint: ' test01 ' }; const res = await request(server) .post('/board') @@ -283,13 +283,13 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessPointCode'); - res.body.accessPointCode.should.equal('TEST01'); + res.body.should.have.property('accessEntryPoint'); + res.body.accessEntryPoint.should.equal('TEST01'); }); - it('should default accessPointCode to null when not provided', async function () { + it('should default accessEntryPoint to null when not provided', async function () { const boardWithoutAccessCode = { ...helper.boardData }; - delete boardWithoutAccessCode.accessPointCode; + delete boardWithoutAccessCode.accessEntryPoint; const res = await request(server) .post('/board') @@ -299,15 +299,15 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.accessPointCode.should.equal(null); + res.body.accessEntryPoint.should.equal(null); }); - it('should update accessPointCode on an existing board', async function () { + it('should update accessEntryPoint on an existing board', async function () { const boardId = await helper.createMochaBoard(server, user.token); const updateData = { ...helper.boardData, - accessPointCode: 'updated01' + accessEntryPoint: 'updated01' }; const res = await request(server) .put('/board/' + boardId) @@ -317,14 +317,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessPointCode'); - res.body.accessPointCode.should.equal('UPDATED01'); + res.body.should.have.property('accessEntryPoint'); + res.body.accessEntryPoint.should.equal('UPDATED01'); }); - it('should return accessPointCode in GET response when set', async function () { + it('should return accessEntryPoint in GET response when set', async function () { const boardWithAccessCode = { ...helper.boardData, - accessPointCode: 'gettest01' + accessEntryPoint: 'gettest01' }; const createRes = await request(server) .post('/board') @@ -343,8 +343,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - getRes.body.should.have.property('accessPointCode'); - getRes.body.accessPointCode.should.equal('GETTEST01'); + getRes.body.should.have.property('accessEntryPoint'); + getRes.body.accessEntryPoint.should.equal('GETTEST01'); }); }); From 342c7cb19eee91727ba02159bcd14d012be2fda1 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 13:19:48 -0300 Subject: [PATCH 057/113] Changed accessEntryPoint to accessGate --- api/models/Board.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index 9bfce651..9a016ead 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -63,7 +63,7 @@ const BOARD_SCHEMA_DEFINITION = { default: false }, grid: {}, - accessEntryPoint: { + accessGate: { type: String, trim: true, uppercase: true, @@ -89,8 +89,8 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse unique index skips documents where accessEntryPoint is null/missing, enforcing uniqueness only for set values -boardSchema.index({ accessEntryPoint: 1 }, { sparse: true, unique: true }); +// Sparse unique index skips documents where accessGate is null/missing, enforcing uniqueness only for set values +boardSchema.index({ accessGate: 1 }, { sparse: true, unique: true }); const validatePresenceOf = value => value && value.length; From 6e4ece28aa42e45c12dcf0e861b51dea1455726c Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 13:20:07 -0300 Subject: [PATCH 058/113] Changed accessEntryPoint to accessGate field --- test/controllers/board.js | 40 +++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index 9fb27cea..397ad6f8 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,11 +252,11 @@ describe('Board API calls', function () { }); }); - describe('accessEntryPoint field behavior', function () { - it('should normalize accessEntryPoint to uppercase when creating a board', async function () { + describe('accessGate field behavior', function () { + it('should normalize accessGate to uppercase when creating a board', async function () { const boardWithAccessCode = { ...helper.boardData, - accessEntryPoint: 'cafe01' + accessGate: 'cafe01' }; const res = await request(server) .post('/board') @@ -266,14 +266,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessEntryPoint'); - res.body.accessEntryPoint.should.equal('CAFE01'); + res.body.should.have.property('accessGate'); + res.body.accessGate.should.equal('CAFE01'); }); - it('should trim whitespace from accessEntryPoint', async function () { + it('should trim whitespace from accessGate', async function () { const boardWithAccessCode = { ...helper.boardData, - accessEntryPoint: ' test01 ' + accessGate: ' test01 ' }; const res = await request(server) .post('/board') @@ -283,13 +283,13 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessEntryPoint'); - res.body.accessEntryPoint.should.equal('TEST01'); + res.body.should.have.property('accessGate'); + res.body.accessGate.should.equal('TEST01'); }); - it('should default accessEntryPoint to null when not provided', async function () { + it('should default accessGate to null when not provided', async function () { const boardWithoutAccessCode = { ...helper.boardData }; - delete boardWithoutAccessCode.accessEntryPoint; + delete boardWithoutAccessCode.accessGate; const res = await request(server) .post('/board') @@ -299,15 +299,15 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.accessEntryPoint.should.equal(null); + res.body.accessGate.should.equal(null); }); - it('should update accessEntryPoint on an existing board', async function () { + it('should update accessGate on an existing board', async function () { const boardId = await helper.createMochaBoard(server, user.token); const updateData = { ...helper.boardData, - accessEntryPoint: 'updated01' + accessGate: 'updated01' }; const res = await request(server) .put('/board/' + boardId) @@ -317,14 +317,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessEntryPoint'); - res.body.accessEntryPoint.should.equal('UPDATED01'); + res.body.should.have.property('accessGate'); + res.body.accessGate.should.equal('UPDATED01'); }); - it('should return accessEntryPoint in GET response when set', async function () { + it('should return accessGate in GET response when set', async function () { const boardWithAccessCode = { ...helper.boardData, - accessEntryPoint: 'gettest01' + accessGate: 'gettest01' }; const createRes = await request(server) .post('/board') @@ -343,8 +343,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - getRes.body.should.have.property('accessEntryPoint'); - getRes.body.accessEntryPoint.should.equal('GETTEST01'); + getRes.body.should.have.property('accessGate'); + getRes.body.accessGate.should.equal('GETTEST01'); }); }); From ca2b7f104f6f3b1a4ffbd7a744fb3fbca389771d Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 14:32:40 -0300 Subject: [PATCH 059/113] Changed accessEntryPoint to accessGate --- api/controllers/access.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index c810b133..afc40b7b 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -192,7 +192,7 @@ async function getAccessBoard(req, res) { * POST /admin/access-clients * Creates a new Access client and its first Access point. * Requires admin authentication (enforced by Swagger middleware). - * Creates AccessClient, creates AccessPoint, and updates boards with accessPointCode. + * Creates AccessClient, creates AccessPoint, and updates boards with accessGate. * Validates rootBoardId exists. */ async function createAccessClient(req, res) { @@ -242,7 +242,7 @@ async function createAccessClient(req, res) { // Mark all discovered boards with the access point code await Board.updateMany( { _id: { $in: linkedBoardIds } }, - { $set: { accessPointCode: accessPoint.code } } + { $set: { accessGate: accessPoint.code } } ); return res.status(201).json({ ...client.toJSON(), accessPoint: accessPoint.toJSON() }); @@ -439,7 +439,7 @@ async function updateAccessPoint(req, res) { // Mark all discovered boards with this access point code await Board.updateMany( { _id: { $in: linkedBoardIds } }, - { $set: { accessPointCode: code } } + { $set: { accessGate: code } } ); return res.status(200).json(accessPoint.toJSON()); From 01af4f4775b17fa7c297a3726db556d03d72ae3f Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 14:32:52 -0300 Subject: [PATCH 060/113] Changed accessEntryPoint to accessGate in tests --- test/controllers/access.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/controllers/access.js b/test/controllers/access.js index d7ef45ba..92b0959e 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -84,9 +84,9 @@ describe('Access API calls', function () { // rootBoard has no tile links, so discovery returns just the root res.body.accessPoint.linkedBoardsIds.length.should.eql(1); - // Verify board was marked with accessPointCode + // Verify board was marked with accessGate const board = await Board.findById(testBoardId); - board.accessPointCode.should.eql('TEST01'); + board.accessGate.should.eql('TEST01'); }); it('it should NOT CREATE with duplicate slug', async function () { @@ -354,9 +354,9 @@ describe('Access API calls', function () { res.body.should.have.property('linkedBoardsIds').that.is.an('array'); res.body.linkedBoardsIds.should.include(testBoardId2.toString()); - // Verify new root board was marked with accessPointCode + // Verify new root board was marked with accessGate const board = await Board.findById(testBoardId2); - board.accessPointCode.should.eql('APUPDATE01'); + board.accessGate.should.eql('APUPDATE01'); }); it('it should re-discover without changing root when no rootBoardId provided', async function () { From 7089ac787888fad415dbeaa6af97e63b6df1149d Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 15:26:55 -0300 Subject: [PATCH 061/113] Changed accessPointCode to accessGate --- api/controllers/board.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 85224cf7..0cbe2688 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -83,7 +83,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true, accessPointCode: null } }, + { query: { ...query, isPublic: true, accessGate: null } }, req.query ); @@ -138,8 +138,8 @@ function getBoard(req, res) { }); } - // If no accessPointCode, serve normally - if (!boards.accessPointCode) { + // If no accessGate, serve normally + if (!boards.accessGate) { return res.status(200).json(boards.toJSON()); } @@ -159,7 +159,7 @@ function getBoard(req, res) { return res.status(403).json({ message: 'This board requires an access code', requiresAccessCode: true, - accessPointCode: boards.accessPointCode + accessGate: boards.accessGate }); } return res.status(200).json(boards.toJSON()); From ee92d31ec5f7a394ec490bfbc88f4dc6a3cdc6d7 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 15:29:38 -0300 Subject: [PATCH 062/113] Changed accessPointCode to accessGate --- api/swagger/swagger.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 95ecc78f..563fa7d9 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1306,7 +1306,7 @@ paths: x-swagger-router-controller: access post: operationId: createAccessClient - description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessPointCode. + description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessGate. security: - Bearer: [] x-security-scopes: @@ -2438,9 +2438,9 @@ definitions: type: boolean grid: type: object - accessPointCode: + accessGate: type: string - description: The access point code associated with this board + description: The access gate code associated with this board AccessPointResponse: type: object properties: From b4fcaffb2e02c86c48e77c02665935c0268eb15c Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 15:59:48 -0300 Subject: [PATCH 063/113] Changed field name to accessGate --- api/controllers/access.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index afc40b7b..6a91c95d 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -204,7 +204,7 @@ async function createAccessClient(req, res) { rootBoardId, subscriptionStart, subscriptionEnd, - accessPointCode + accessGate } = req.body; try { @@ -231,7 +231,7 @@ async function createAccessClient(req, res) { // Create the access point const accessPoint = new AccessPoint({ - code: accessPointCode.toUpperCase(), + code: accessGate.toUpperCase(), accessClient: client._id, rootBoardId, linkedBoardsIds: linkedBoardIds From f30b5830837c15ff5473a1fa012f9c1986f3ecc8 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 15:59:58 -0300 Subject: [PATCH 064/113] Changed field name to accessGate --- test/controllers/access.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/controllers/access.js b/test/controllers/access.js index 92b0959e..b26006e6 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -61,7 +61,7 @@ describe('Access API calls', function () { clientContact: 'test@example.com', brandColor: '#FF5733', rootBoardId: testBoardId, - accessPointCode: 'TEST01', + accessGate: 'TEST01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -94,7 +94,7 @@ describe('Access API calls', function () { slug: 'duplicate', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessPointCode: 'DUPL01', + accessGate: 'DUPL01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -109,7 +109,7 @@ describe('Access API calls', function () { // Try to create with same slug const res = await request(server) .post('/admin/access-clients') - .send({ ...clientData, accessPointCode: 'DUPL02' }) + .send({ ...clientData, accessGate: 'DUPL02' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -123,7 +123,7 @@ describe('Access API calls', function () { slug: 'duplicate-code-a', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessPointCode: 'DUPCODE', + accessGate: 'DUPCODE', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -135,7 +135,7 @@ describe('Access API calls', function () { .set('Authorization', `Bearer ${adminUser.token}`) .expect(201); - // Try to create with same accessPointCode but different slug + // Try to create with same accessGate but different slug const res = await request(server) .post('/admin/access-clients') .send({ ...clientData, slug: 'duplicate-code-b' }) @@ -152,7 +152,7 @@ describe('Access API calls', function () { slug: 'test-04', clientName: 'Test Client mocha test', rootBoardId: '507f1f77bcf86cd799439011', // Non-existent ID - accessPointCode: 'TEST04', + accessGate: 'TEST04', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -173,7 +173,7 @@ describe('Access API calls', function () { slug: 'test-05', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessPointCode: 'TEST05', + accessGate: 'TEST05', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -196,7 +196,7 @@ describe('Access API calls', function () { slug: 'list-01', clientName: 'List Test 1 mocha test', rootBoardId: testBoardId, - accessPointCode: 'LIST01', + accessGate: 'LIST01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }) @@ -208,7 +208,7 @@ describe('Access API calls', function () { slug: 'list-02', clientName: 'List Test 2 mocha test', rootBoardId: testBoardId, - accessPointCode: 'LIST02', + accessGate: 'LIST02', subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), // Expired }) @@ -258,7 +258,7 @@ describe('Access API calls', function () { slug: 'update-01', clientName: 'Update Test mocha test', rootBoardId: testBoardId, - accessPointCode: 'UPDATE01', + accessGate: 'UPDATE01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), brandColor: '#000000', @@ -334,7 +334,7 @@ describe('Access API calls', function () { slug: 'ap-update-01', clientName: 'Access Point Update Test mocha test', rootBoardId: testBoardId, - accessPointCode: 'APUPDATE01', + accessGate: 'APUPDATE01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }) @@ -402,7 +402,7 @@ describe('Access API calls', function () { slug: 'stats-01', clientName: 'Stats Test mocha test', rootBoardId: testBoardId, - accessPointCode: 'STATS01', + accessGate: 'STATS01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }) @@ -448,7 +448,7 @@ describe('Access API calls', function () { slug: 'expired-01', clientName: 'Expired Test mocha test', rootBoardId: testBoardId, - accessPointCode: 'EXPIRED01', + accessGate: 'EXPIRED01', subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), }) From de67824b57a30c65e00691b5e6b6f763d17bc5a9 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 16:02:49 -0300 Subject: [PATCH 065/113] Change accessPointCode to accessGate --- api/swagger/swagger.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 563fa7d9..56bda58f 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2168,13 +2168,13 @@ definitions: - rootBoardId - subscriptionStart - subscriptionEnd - - accessPointCode + - accessGate properties: slug: type: string description: Unique slug identifier for the access client (lowercase) example: my-company - accessPointCode: + accessGate: type: string description: Unique code for the access point (will be uppercased) example: ABC123 From 85776a3b0f8e2cf38785da090400408141d0e5c6 Mon Sep 17 00:00:00 2001 From: Agus Date: Thu, 16 Apr 2026 16:05:36 -0300 Subject: [PATCH 066/113] Changed accessPointCode to accessGate --- test/postman/CboardAccess.collection.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 56f779f4..3fa9537d 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -65,7 +65,7 @@ ], "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 \"accessPointCode\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" + "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 \"accessGate\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" }, "url": { "raw": "{{url}}/admin/access-clients", From 86440f506c6af336b919cada6565860ba21a8dbc Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 17 Apr 2026 14:28:22 -0300 Subject: [PATCH 067/113] Rename accessGate to accessGateCode in Board model and update related tests --- api/models/Board.js | 6 +++--- test/controllers/board.js | 40 +++++++++++++++++++-------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index 0fadfbe6..dcf931ca 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -63,7 +63,7 @@ const BOARD_SCHEMA_DEFINITION = { default: false }, grid: {}, - accessGate: { + accessGateCode: { type: String, trim: true, uppercase: true, @@ -87,8 +87,8 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse unique index skips documents where accessGate is null/missing, enforcing uniqueness only for set values -boardSchema.index({ accessGate: 1 }, { sparse: true, unique: true }); +// Sparse unique index skips documents where accessGateCode is null/missing, enforcing uniqueness only for set values +boardSchema.index({ accessGateCode: 1 }, { sparse: true, unique: true }); const validatePresenceOf = value => value && value.length; diff --git a/test/controllers/board.js b/test/controllers/board.js index 397ad6f8..026b640f 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -252,11 +252,11 @@ describe('Board API calls', function () { }); }); - describe('accessGate field behavior', function () { - it('should normalize accessGate to uppercase when creating a board', async function () { + describe('accessGateCode field behavior', function () { + it('should normalize accessGateCode to uppercase when creating a board', async function () { const boardWithAccessCode = { ...helper.boardData, - accessGate: 'cafe01' + accessGateCode: 'cafe01' }; const res = await request(server) .post('/board') @@ -266,14 +266,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessGate'); - res.body.accessGate.should.equal('CAFE01'); + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('CAFE01'); }); - it('should trim whitespace from accessGate', async function () { + it('should trim whitespace from accessGateCode', async function () { const boardWithAccessCode = { ...helper.boardData, - accessGate: ' test01 ' + accessGateCode: ' test01 ' }; const res = await request(server) .post('/board') @@ -283,13 +283,13 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessGate'); - res.body.accessGate.should.equal('TEST01'); + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('TEST01'); }); - it('should default accessGate to null when not provided', async function () { + it('should default accessGateCode to null when not provided', async function () { const boardWithoutAccessCode = { ...helper.boardData }; - delete boardWithoutAccessCode.accessGate; + delete boardWithoutAccessCode.accessGateCode; const res = await request(server) .post('/board') @@ -299,15 +299,15 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.accessGate.should.equal(null); + res.body.accessGateCode.should.equal(null); }); - it('should update accessGate on an existing board', async function () { + it('should update accessGateCode on an existing board', async function () { const boardId = await helper.createMochaBoard(server, user.token); const updateData = { ...helper.boardData, - accessGate: 'updated01' + accessGateCode: 'updated01' }; const res = await request(server) .put('/board/' + boardId) @@ -317,14 +317,14 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.should.have.property('accessGate'); - res.body.accessGate.should.equal('UPDATED01'); + res.body.should.have.property('accessGateCode'); + res.body.accessGateCode.should.equal('UPDATED01'); }); - it('should return accessGate in GET response when set', async function () { + it('should return accessGateCode in GET response when set', async function () { const boardWithAccessCode = { ...helper.boardData, - accessGate: 'gettest01' + accessGateCode: 'gettest01' }; const createRes = await request(server) .post('/board') @@ -343,8 +343,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - getRes.body.should.have.property('accessGate'); - getRes.body.accessGate.should.equal('GETTEST01'); + getRes.body.should.have.property('accessGateCode'); + getRes.body.accessGateCode.should.equal('GETTEST01'); }); }); From 8c7e340ac2785469e96af248d2f69ab72babfbe7 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 17 Apr 2026 14:28:55 -0300 Subject: [PATCH 068/113] Rename AccesPoint to AccessGate model with schema definition and options --- api/models/{AccessPoint.js => AccessGate.js} | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) rename api/models/{AccessPoint.js => AccessGate.js} (74%) diff --git a/api/models/AccessPoint.js b/api/models/AccessGate.js similarity index 74% rename from api/models/AccessPoint.js rename to api/models/AccessGate.js index 42a8881b..f125fb9b 100644 --- a/api/models/AccessPoint.js +++ b/api/models/AccessGate.js @@ -3,7 +3,7 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const ACCESS_POINT_SCHEMA_DEFINITION = { +const ACCESS_GATE_SCHEMA_DEFINITION = { code: { type: String, required: true, @@ -37,7 +37,7 @@ const ACCESS_POINT_SCHEMA_DEFINITION = { } }; -const ACCESS_POINT_SCHEMA_OPTIONS = { +const ACCESS_GATE_SCHEMA_OPTIONS = { timestamps: true, toObject: { virtuals: true @@ -52,11 +52,11 @@ const ACCESS_POINT_SCHEMA_OPTIONS = { } }; -const accessPointSchema = new Schema( - ACCESS_POINT_SCHEMA_DEFINITION, - ACCESS_POINT_SCHEMA_OPTIONS +const accessGateSchema = new Schema( + ACCESS_GATE_SCHEMA_DEFINITION, + ACCESS_GATE_SCHEMA_OPTIONS ); -const AccessPoint = mongoose.model('AccessPoint', accessPointSchema); +const AccessGate = mongoose.model('AccessGate', accessGateSchema); -module.exports = AccessPoint; +module.exports = AccessGate; From c49c1f5f55b21e0fc96ea7905708a40a19a5286f Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 12:51:54 -0300 Subject: [PATCH 069/113] Solved merge problems --- api/controllers/access.js | 20 ++++++++++---------- test/controllers/access.js | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 6a91c95d..dd055acb 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -1,7 +1,7 @@ 'use strict'; const AccessClient = require('../models/AccessClient'); -const AccessPoint = require('../models/AccessPoint'); +const AccessGate = require('../models/AccessGate'); const Board = require('../models/Board'); module.exports = { @@ -63,7 +63,7 @@ async function getClients(req, res) { subscriptionEnd: { $gte: now } }).select('slug client brandColor'); - const accessPoints = await AccessPoint.find({ + const accessPoints = await AccessGate.find({ accessClient: { $in: clients.map(c => c._id) } }) .populate('rootBoardId', 'name caption tiles') @@ -115,7 +115,7 @@ async function getAccessBoard(req, res) { try { const now = new Date(); - const accessPoint = await AccessPoint.findOne({ code }).populate( + const accessPoint = await AccessGate.findOne({ code }).populate( 'accessClient' ); @@ -159,7 +159,7 @@ async function getAccessBoard(req, res) { } // Track access analytics atomically to avoid lost updates under concurrency - await AccessPoint.updateOne( + await AccessGate.updateOne( { _id: accessPoint._id }, { $inc: { viewsCount: 1 }, @@ -230,7 +230,7 @@ async function createAccessClient(req, res) { const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); // Create the access point - const accessPoint = new AccessPoint({ + const accessPoint = new AccessGate({ code: accessGate.toUpperCase(), accessClient: client._id, rootBoardId, @@ -242,7 +242,7 @@ async function createAccessClient(req, res) { // Mark all discovered boards with the access point code await Board.updateMany( { _id: { $in: linkedBoardIds } }, - { $set: { accessGate: accessPoint.code } } + { $set: { accessGateCode: accessPoint.code } } ); return res.status(201).json({ ...client.toJSON(), accessPoint: accessPoint.toJSON() }); @@ -274,7 +274,7 @@ async function listAccessClients(req, res) { // Fetch access points and sum linkedBoardsIds per client to avoid N+1 const clientIds = clients.map(c => c._id); - const accessPoints = await AccessPoint.find({ accessClient: { $in: clientIds } }); + const accessPoints = await AccessGate.find({ accessClient: { $in: clientIds } }); const apMap = accessPoints.reduce((map, ap) => { const key = ap.accessClient.toString(); @@ -362,7 +362,7 @@ async function getAccessClientStats(req, res) { return res.status(404).json({ message: 'Client not found' }); } - const accessPoints = await AccessPoint.find({ accessClient: client._id }); + const accessPoints = await AccessGate.find({ accessClient: client._id }); // Collect unique board IDs across all access points const allBoardIds = [ @@ -416,7 +416,7 @@ async function updateAccessPoint(req, res) { const { rootBoardId } = req.body; try { - const accessPoint = await AccessPoint.findOne({ code }); + const accessPoint = await AccessGate.findOne({ code }); if (!accessPoint) { return res.status(404).json({ message: 'Access point not found' }); } @@ -439,7 +439,7 @@ async function updateAccessPoint(req, res) { // Mark all discovered boards with this access point code await Board.updateMany( { _id: { $in: linkedBoardIds } }, - { $set: { accessGate: code } } + { $set: { accessGateCode: code } } ); return res.status(200).json(accessPoint.toJSON()); diff --git a/test/controllers/access.js b/test/controllers/access.js index b26006e6..e50da9a7 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -9,7 +9,7 @@ const should = chai.should(); const helper = require('../helper'); const AccessClient = require('../../api/models/AccessClient'); -const AccessPoint = require('../../api/models/AccessPoint'); +const AccessGate = require('../../api/models/AccessGate'); const Board = require('../../api/models/Board'); //Parent block @@ -49,7 +49,7 @@ describe('Access API calls', function () { await Board.deleteMany({ author: 'cboard mocha test' }); const clients = await AccessClient.find({ 'client.name': /mocha test/i }); const clientIds = clients.map(c => c._id); - await AccessPoint.deleteMany({ accessClient: { $in: clientIds } }); + await AccessGate.deleteMany({ accessClient: { $in: clientIds } }); await AccessClient.deleteMany({ 'client.name': /mocha test/i }); }); @@ -86,7 +86,7 @@ describe('Access API calls', function () { // Verify board was marked with accessGate const board = await Board.findById(testBoardId); - board.accessGate.should.eql('TEST01'); + board.accessGateCode.should.eql('TEST01'); }); it('it should NOT CREATE with duplicate slug', async function () { @@ -356,7 +356,7 @@ describe('Access API calls', function () { // Verify new root board was marked with accessGate const board = await Board.findById(testBoardId2); - board.accessGate.should.eql('APUPDATE01'); + board.accessGateCode.should.eql('APUPDATE01'); }); it('it should re-discover without changing root when no rootBoardId provided', async function () { From 7ccff4168f047031c8a588fdf1c2d62cdc0c450f Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 13:06:15 -0300 Subject: [PATCH 070/113] Changed accessPoint to accessGateCode --- api/controllers/board.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 0cbe2688..d55f25e4 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -4,7 +4,7 @@ const moment = require('moment'); const { paginatedResponse } = require('../helpers/response'); const { getORQuery } = require('../helpers/query'); const Board = require('../models/Board'); -const AccessPoint = require('../models/AccessPoint'); +const AccessGate = require('../models/AccessGate'); const User = require('../models/User'); const { getTokenData } = require('../helpers/auth'); const { getCbuilderBoardbyId } = require('../helpers/cbuilder'); @@ -83,7 +83,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true, accessGate: null } }, + { query: { ...query, isPublic: true, accessGateCode: null } }, req.query ); @@ -105,11 +105,11 @@ async function deleteBoard(req, res) { error: 'Board not found.' }); } - await AccessPoint.updateMany( + await AccessGate.updateMany( { linkedBoardsIds: id }, { $pull: { linkedBoardsIds: id } } ); - await AccessPoint.updateMany( + await AccessGate.updateMany( { rootBoardId: id }, { $set: { rootBoardId: null } } ); @@ -138,8 +138,8 @@ function getBoard(req, res) { }); } - // If no accessGate, serve normally - if (!boards.accessGate) { + // If no accessGateCode, serve normally + if (!boards.accessGateCode) { return res.status(200).json(boards.toJSON()); } @@ -159,7 +159,7 @@ function getBoard(req, res) { return res.status(403).json({ message: 'This board requires an access code', requiresAccessCode: true, - accessGate: boards.accessGate + accessGate: boards.accessGateCode }); } return res.status(200).json(boards.toJSON()); From 53972e22f3c3bcee7591762bce9db8ed1cffcbe2 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 13:06:30 -0300 Subject: [PATCH 071/113] Changed accessPoint to accessGateCode --- test/controllers/board.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index f70f7aca..6c7f1a35 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -361,8 +361,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - adminRes.body.should.have.property('accessGate'); - adminRes.body.accessGate.should.equal('GETTEST01'); + adminRes.body.should.have.property('accessGateCode'); + adminRes.body.accessGateCode.should.equal('GETTEST01'); }); it('should exclude boards with accessGate from public boards listing', async function () { @@ -384,7 +384,7 @@ describe('Board API calls', function () { ...helper.boardData, name: 'Public Board With Code', isPublic: true, - accessGate: 'public01' + accessGateCode: 'public01' }; await request(server) .post('/board') @@ -400,8 +400,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - getRes.body.should.have.property('accessGateCode'); - getRes.body.accessGateCode.should.equal('GETTEST01'); + const boardsWithCode = res.body.data.filter(b => b.name === 'Public Board With Code'); + boardsWithCode.length.should.equal(0); }); }); From 199915b355797ca72490f4fce7b9bf756a62646d Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 13:12:21 -0300 Subject: [PATCH 072/113] Changed accessGate to accessGateCode --- api/swagger/swagger.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 56bda58f..b7a38351 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1306,7 +1306,7 @@ paths: x-swagger-router-controller: access post: operationId: createAccessClient - description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessGate. + description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessGateCode. security: - Bearer: [] x-security-scopes: @@ -2438,7 +2438,7 @@ definitions: type: boolean grid: type: object - accessGate: + accessGateCode: type: string description: The access gate code associated with this board AccessPointResponse: From 25aa3d4dc6af3ca27fda5502735c83258004be23 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 14:13:34 -0300 Subject: [PATCH 073/113] Changed endpoint to /access/clients/all --- api/controllers/access.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index dd055acb..b2ea0d2e 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -49,7 +49,7 @@ async function getAllLinkedBoardIds(rootBoardId) { } /** - * GET /access/clients + * GET /access/clients/all * Lists all active clients with valid subscriptions and their access points. * Returns each client with its access points and root board basic info. * Sorted by client name. From 6ef156a29b9368ac7566e30265e0bd069fb19430 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 14:16:08 -0300 Subject: [PATCH 074/113] Changed endpoint to /access/clients/all --- api/swagger/swagger.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index b7a38351..e912845b 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1264,7 +1264,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /access/clients: + /access/clients/all: x-swagger-router-controller: access get: operationId: getClients From 6dc5d66b1614bce2e84baa3af8ffe5561d783e11 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 14:17:45 -0300 Subject: [PATCH 075/113] Changed endpoint to /access/clients/all --- test/postman/CboardAccess.collection.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 3fa9537d..473a1b6c 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -402,13 +402,14 @@ "method": "GET", "header": [], "url": { - "raw": "{{url}}/access/clients", + "raw": "{{url}}/access/clients/all", "host": [ "{{url}}" ], "path": [ "access", - "clients" + "clients", + "all" ] }, "description": "Public endpoint that lists all active Cboard Access clients for display in the app. No authentication required." From ccbc920ba44d594576c5eade4156fce8d6b3e06d Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 15:03:51 -0300 Subject: [PATCH 076/113] Refactor access terminology from "AccessPoint" to "AccessGate" across controllers, Swagger documentation, and tests --- api/controllers/access.js | 118 +++++++++++----------- api/swagger/swagger.yaml | 32 +++--- test/controllers/access.js | 26 ++--- test/postman/CboardAccess.README.md | 18 ++-- test/postman/CboardAccess.collection.json | 38 +++---- 5 files changed, 116 insertions(+), 116 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 476f736e..75b7b891 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -10,7 +10,7 @@ module.exports = { createAccessClient: createAccessClient, listAccessClients: listAccessClients, updateAccessClient: updateAccessClient, - updateAccessPoint: updateAccessPoint, + updateAccessGate: updateAccessGate, getAccessClientStats: getAccessClientStats }; @@ -50,8 +50,8 @@ async function getAllLinkedBoardIds(rootBoardId) { /** * GET /access/clients/all - * Lists all active clients with valid subscriptions and their access points. - * Returns each client with its access points and root board basic info. + * 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) { @@ -63,7 +63,7 @@ async function getClients(req, res) { subscriptionEnd: { $gte: now } }).select('slug client brandColor'); - const accessPoints = await AccessGate.find({ + const accessGates = await AccessGate.find({ accessClient: { $in: clients.map(c => c._id) } }) .populate('rootBoardId', 'name caption tiles') @@ -74,16 +74,16 @@ async function getClients(req, res) { slug: client.slug, clientName: client.client.name, brandColor: client.brandColor, - accessPoints: accessPoints - .filter(ap => ap.accessClient.toString() === client._id.toString()) - .map(ap => ({ - code: ap.code, - rootBoard: ap.rootBoardId + accessGates: accessGates + .filter(ag => ag.accessClient.toString() === client._id.toString()) + .map(ag => ({ + code: ag.code, + rootBoard: ag.rootBoardId ? { - id: ap.rootBoardId._id, - name: ap.rootBoardId.name, - caption: ap.rootBoardId.caption, - tilesCount: ap.rootBoardId.tiles?.length || 0 + id: ag.rootBoardId._id, + name: ag.rootBoardId.name, + caption: ag.rootBoardId.caption, + tilesCount: ag.rootBoardId.tiles?.length || 0 } : null })) @@ -108,24 +108,24 @@ async function getClients(req, res) { * This enables instant frontend navigation without additional requests. * Validates code exists and client subscription is active. * Returns client info (slug, name, color), all boards array, and rootBoardId. - * Increments viewsCount and updates lastAccessAt on the AccessPoint. + * Increments viewsCount and updates lastAccessAt on the AccessGate. */ async function getAccessBoard(req, res) { const code = req.swagger.params.code.value.toUpperCase(); try { const now = new Date(); - const accessPoint = await AccessGate.findOne({ code }).populate( + const accessGate = await AccessGate.findOne({ code }).populate( 'accessClient' ); - if (!accessPoint) { + if (!accessGate) { return res.status(404).json({ message: 'Invalid access code' }); } - const client = accessPoint.accessClient; + const client = accessGate.accessClient; if ( !client || !client.isActive || @@ -139,7 +139,7 @@ async function getAccessBoard(req, res) { // Fetch all linked boards (exclude PII fields for public endpoint) const boards = await Board.find({ - _id: { $in: accessPoint.linkedBoardsIds } + _id: { $in: accessGate.linkedBoardsIds } }).select('-email -author'); if (!boards || boards.length === 0) { @@ -150,7 +150,7 @@ async function getAccessBoard(req, res) { // Verify the rootBoard exists among the linked boards const rootBoard = boards.find( - b => b._id.toString() === accessPoint.rootBoardId.toString() + b => b._id.toString() === accessGate.rootBoardId.toString() ); if (!rootBoard) { return res.status(404).json({ @@ -160,7 +160,7 @@ async function getAccessBoard(req, res) { // Track access analytics atomically to avoid lost updates under concurrency await AccessGate.updateOne( - { _id: accessPoint._id }, + { _id: accessGate._id }, { $inc: { viewsCount: 1 }, $set: { lastAccessAt: new Date() } @@ -174,7 +174,7 @@ async function getAccessBoard(req, res) { color: client.brandColor }, boards: boards.map(b => b.toJSON()), - rootBoardId: accessPoint.rootBoardId.toString() + rootBoardId: accessGate.rootBoardId.toString() }); } catch (err) { return res.status(500).json({ @@ -190,9 +190,9 @@ async function getAccessBoard(req, res) { /** * POST /admin/access-clients - * Creates a new Access client and its first Access point. + * Creates a new Access client and its first Access gate. * Requires admin authentication (enforced by Swagger middleware). - * Creates AccessClient, creates AccessPoint, and updates boards with accessGate. + * Creates AccessClient, creates AccessGate, and updates boards with accessGate. * Validates rootBoardId exists. */ async function createAccessClient(req, res) { @@ -230,23 +230,23 @@ async function createAccessClient(req, res) { // Auto-discover all boards reachable from root via tile.loadBoard links const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); - // Create the access point - const accessPoint = new AccessGate({ + // Create the access gate + const newAccessGate = new AccessGate({ code: accessGate.toUpperCase(), accessClient: client._id, rootBoardId, linkedBoardsIds: linkedBoardIds }); - await accessPoint.save(); + await newAccessGate.save(); - // Mark all discovered boards with the access point code + // Mark all discovered boards with the access gate code await Board.updateMany( { _id: { $in: linkedBoardIds } }, - { $set: { accessGateCode: accessPoint.code } } + { $set: { accessGateCode: newAccessGate.code } } ); - return res.status(201).json({ ...client.toJSON(), accessPoint: accessPoint.toJSON() }); + return res.status(201).json({ ...client.toJSON(), accessGate: newAccessGate.toJSON() }); } catch (err) { if (err.code === 11000) { const isSlugDuplicate = err.message.includes('slug'); @@ -265,7 +265,7 @@ async function createAccessClient(req, res) { * GET /admin/access-clients * Lists all Access clients with stats. * Returns all clients with board counts, expiry status. - * Populates createdBy. Board count derived from AccessPoint.linkedBoardsIds. + * Populates createdBy. Board count derived from AccessGate.linkedBoardsIds. */ async function listAccessClients(req, res) { try { @@ -273,20 +273,20 @@ async function listAccessClients(req, res) { .populate('createdBy', 'name email role') .sort({ createdAt: -1 }); - // Fetch access points and sum linkedBoardsIds per client to avoid N+1 + // Fetch access gates and sum linkedBoardsIds per client to avoid N+1 const clientIds = clients.map(c => c._id); - const accessPoints = await AccessGate.find({ accessClient: { $in: clientIds } }); + const accessGates = await AccessGate.find({ accessClient: { $in: clientIds } }); - const apMap = accessPoints.reduce((map, ap) => { - const key = ap.accessClient.toString(); + const agMap = accessGates.reduce((map, ag) => { + const key = ag.accessClient.toString(); if (!map[key]) map[key] = 0; - map[key] += ap.linkedBoardsIds?.length || 0; + map[key] += ag.linkedBoardsIds?.length || 0; return map; }, {}); const now = new Date(); const result = clients.map(client => { - const boardCount = apMap[client._id.toString()] || 0; + const boardCount = agMap[client._id.toString()] || 0; const daysUntilExpiry = Math.ceil( (client.subscriptionEnd - now) / (1000 * 60 * 60 * 24) ); @@ -351,7 +351,7 @@ async function updateAccessClient(req, res) { /** * GET /admin/access-clients/:slug/stats * Gets detailed statistics for an Access client. - * Returns client info, access counts (from AccessPoints), board list with tile counts. + * Returns client info, access counts (from AccessGates), board list with tile counts. */ async function getAccessClientStats(req, res) { const slug = req.swagger.params.slug.value; @@ -364,18 +364,18 @@ async function getAccessClientStats(req, res) { return res.status(404).json({ message: 'Client not found' }); } - const accessPoints = await AccessGate.find({ accessClient: client._id }); + const accessGates = await AccessGate.find({ accessClient: client._id }); - // Collect unique board IDs across all access points + // Collect unique board IDs across all access gates const allBoardIds = [ - ...new Set(accessPoints.flatMap(ap => ap.linkedBoardsIds.map(id => id.toString()))) + ...new Set(accessGates.flatMap(ag => ag.linkedBoardsIds.map(id => id.toString()))) ]; const boards = await Board.find({ _id: { $in: allBoardIds } }, { name: 1, tiles: 1 }); - const totalAccesses = accessPoints.reduce((sum, ap) => sum + (ap.viewsCount || 0), 0); - const lastAccessAt = accessPoints.reduce((latest, ap) => { - if (!ap.lastAccessAt) return latest; - if (!latest || ap.lastAccessAt > latest) return ap.lastAccessAt; + 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); @@ -408,22 +408,22 @@ async function getAccessClientStats(req, res) { } /** - * PUT /admin/access-points/:code - * Re-runs board discovery for an access point, updating its linkedBoardsIds. + * PUT /admin/access-gates/:code + * Re-runs board discovery for an access gate, updating its linkedBoardsIds. * Optionally accepts a new rootBoardId to change the root and re-discover from there. - * Useful when the board structure has changed since the access point was created. + * Useful when the board structure has changed since the access gate was created. */ -async function updateAccessPoint(req, res) { +async function updateAccessGate(req, res) { const code = req.swagger.params.code.value.toUpperCase(); const { rootBoardId } = req.body; try { - const accessPoint = await AccessGate.findOne({ code }); - if (!accessPoint) { - return res.status(404).json({ message: 'Access point not found' }); + const accessGate = await AccessGate.findOne({ code }); + if (!accessGate) { + return res.status(404).json({ message: 'Access gate not found' }); } - const newRootBoardId = rootBoardId || accessPoint.rootBoardId; + const newRootBoardId = rootBoardId || accessGate.rootBoardId; // Verify root board exists const rootBoard = await Board.findById(newRootBoardId); @@ -434,21 +434,21 @@ async function updateAccessPoint(req, res) { // Re-discover all boards reachable from root const linkedBoardIds = await getAllLinkedBoardIds(newRootBoardId); - accessPoint.rootBoardId = newRootBoardId; - accessPoint.linkedBoardsIds = linkedBoardIds; - await accessPoint.save(); + accessGate.rootBoardId = newRootBoardId; + accessGate.linkedBoardsIds = linkedBoardIds; + await accessGate.save(); - // Mark all discovered boards with this access point code + // Mark all discovered boards with this access gate code await Board.updateMany( { _id: { $in: linkedBoardIds } }, { $set: { accessGateCode: code } } ); - return res.status(200).json(accessPoint.toJSON()); + return res.status(200).json(accessGate.toJSON()); } catch (err) { return res.status(500).json({ - message: 'Error updating access point', + message: 'Error updating access gate', error: err.message }); } -} +} \ No newline at end of file diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index e912845b..fba6a988 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1306,7 +1306,7 @@ paths: x-swagger-router-controller: access post: operationId: createAccessClient - description: Creates a new Access client and its first AccessPoint. Requires admin authentication. Auto-discovers linked boards from rootBoardId and marks them with accessGateCode. + 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: @@ -1441,11 +1441,11 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-points/{code}: + /admin/access-gates/{code}: x-swagger-router-controller: access put: - operationId: updateAccessPoint - description: Re-runs board discovery for an access point, updating its linkedBoardsIds. Optionally accepts a new rootBoardId. Requires admin authentication. + operationId: updateAccessGate + description: Re-runs board discovery for an access gate, updating its linkedBoardsIds. Optionally accepts a new rootBoardId. Requires admin authentication. security: - Bearer: [] x-security-scopes: @@ -1455,7 +1455,7 @@ paths: type: string in: path required: true - description: Access point code (uppercase) + description: Access gate code (uppercase) - name: body in: body required: false @@ -1469,9 +1469,9 @@ paths: "200": description: Success schema: - $ref: "#/definitions/AccessPointResponse" + $ref: "#/definitions/AccessGateResponse" "404": - description: Access point or root board not found + description: Access gate or root board not found schema: $ref: "#/definitions/ErrorResponse" default: @@ -2176,7 +2176,7 @@ definitions: example: my-company accessGate: type: string - description: Unique code for the access point (will be uppercased) + description: Unique code for the access gate (will be uppercased) example: ABC123 clientName: type: string @@ -2275,9 +2275,9 @@ definitions: type: string format: date-time description: Last update timestamp - accessPoint: + accessGate: description: Present only on create response - $ref: "#/definitions/AccessPointResponse" + $ref: "#/definitions/AccessGateResponse" AccessClientsListResponse: type: object required: @@ -2301,14 +2301,14 @@ definitions: brandColor: type: string description: Brand color (hex) - accessPoints: + accessGates: type: array items: type: object properties: code: type: string - description: Access point code + description: Access gate code rootBoard: type: object properties: @@ -2373,7 +2373,7 @@ definitions: format: date-time boardCount: type: integer - description: Total boards across all access points for this client + description: Total boards across all access gates for this client isExpired: type: boolean description: Whether the subscription has expired @@ -2441,14 +2441,14 @@ definitions: accessGateCode: type: string description: The access gate code associated with this board - AccessPointResponse: + AccessGateResponse: type: object properties: id: type: string code: type: string - description: Access point code (uppercase) + description: Access gate code (uppercase) accessClient: type: string description: AccessClient ID @@ -2462,7 +2462,7 @@ definitions: description: All board IDs reachable from the root board viewsCount: type: integer - description: Number of times this access point has been accessed + description: Number of times this access gate has been accessed lastAccessAt: type: string format: date-time diff --git a/test/controllers/access.js b/test/controllers/access.js index e50da9a7..5f94fb7e 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -79,10 +79,10 @@ describe('Access API calls', function () { res.body.client.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('accessPoint'); - res.body.accessPoint.should.have.property('code').eql('TEST01'); + 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.accessPoint.linkedBoardsIds.length.should.eql(1); + res.body.accessGate.linkedBoardsIds.length.should.eql(1); // Verify board was marked with accessGate const board = await Board.findById(testBoardId); @@ -118,7 +118,7 @@ describe('Access API calls', function () { res.body.should.have.property('message').eql('Slug already exists'); }); - it('it should NOT CREATE with duplicate access point code', async function () { + it('it should NOT CREATE with duplicate access gate code', async function () { const clientData = { slug: 'duplicate-code-a', clientName: 'Test Client mocha test', @@ -326,13 +326,13 @@ describe('Access API calls', function () { }); }); - describe('PUT /admin/access-points/:code', function () { + describe('PUT /admin/access-gates/:code', function () { beforeEach(async function () { await request(server) .post('/admin/access-clients') .send({ slug: 'ap-update-01', - clientName: 'Access Point Update Test mocha test', + clientName: 'Access Gate Update Test mocha test', rootBoardId: testBoardId, accessGate: 'APUPDATE01', subscriptionStart: new Date(), @@ -341,9 +341,9 @@ describe('Access API calls', function () { .set('Authorization', `Bearer ${adminUser.token}`); }); - it('it should re-discover boards when updating access point root', async function () { + it('it should re-discover boards when updating access gate root', async function () { const res = await request(server) - .put('/admin/access-points/APUPDATE01') + .put('/admin/access-gates/APUPDATE01') .send({ rootBoardId: testBoardId2 }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -361,7 +361,7 @@ describe('Access API calls', function () { it('it should re-discover without changing root when no rootBoardId provided', async function () { const res = await request(server) - .put('/admin/access-points/APUPDATE01') + .put('/admin/access-gates/APUPDATE01') .send({}) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -372,21 +372,21 @@ describe('Access API calls', function () { res.body.linkedBoardsIds.should.include(testBoardId.toString()); }); - it('it should return 404 for non-existent access point code', async function () { + it('it should return 404 for non-existent access gate code', async function () { const res = await request(server) - .put('/admin/access-points/NONEXISTENT') + .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 point not found'); + 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-points/APUPDATE01') + .put('/admin/access-gates/APUPDATE01') .send({}) .set('Accept', 'application/json') .expect('Content-Type', /json/) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 570e178e..d4cf2c38 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -12,12 +12,12 @@ Cboard Access allows businesses to offer AAC boards in their locations via QR co - **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 point +- **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 Point**: Re-run board discovery for an access point +- **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 @@ -54,7 +54,7 @@ The collection includes these variables (can be edited in collection variables o | `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 point code for public board access (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 @@ -82,13 +82,13 @@ The collection includes these variables (can be edited in collection variables o "clientPhone": "+1234567890", "brandColor": "#8B4513", "rootBoardId": "{{root_board_id}}", - "accessPointCode": "CAFE01", + "accessGate": "CAFE01", "subscriptionStart": "2026-04-01T00:00:00.000Z", "subscriptionEnd": "2027-04-01T00:00:00.000Z" } ``` -2. The response includes the created client (`slug`, `client.name`, etc.) and the `accessPoint` with `code` and `linkedBoardsIds` +2. The response includes the created client (`slug`, `client.name`, etc.) and the `accessGate` with `code` and `linkedBoardsIds` 3. `client_slug` and `access_code` variables are automatically saved for subsequent requests ### Testing the Client @@ -105,7 +105,7 @@ The collection includes these variables (can be edited in collection variables o ### Re-discovering Boards -Use **Update Access Point** to re-run board discovery after the board structure changes. Optionally pass a new `rootBoardId` in the body to change the root. +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 @@ -115,7 +115,7 @@ Use **Update Access Point** to re-run board discovery after the board structure - `slug`: Unique client identifier (lowercase, URL-safe) - `clientName`: Display name - `rootBoardId`: ID of the main board -- `accessPointCode`: Unique code for the access point (uppercase alphanumeric) +- `accessGate`: Unique code for the access gate (uppercase alphanumeric) - `subscriptionStart`: Start date (ISO 8601) - `subscriptionEnd`: End date (ISO 8601) @@ -124,7 +124,7 @@ Use **Update Access Point** to re-run board discovery after the board structure - `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 point. +Board discovery is automatic — all boards reachable from `rootBoardId` via tile navigation are linked to the access gate. ### Update Client @@ -178,7 +178,7 @@ Each request includes automated tests: ### Scenario 4: Board Structure Changed 1. Update boards (add/remove tile navigation) -2. Run "Update Access Point" to re-discover linked boards +2. Run "Update Access Gate" to re-discover linked boards 3. Verify `linkedBoardsIds` count updated ## Troubleshooting diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 473a1b6c..d9900572 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -28,22 +28,22 @@ " pm.expect(jsonData.client).to.have.property('name');", "});", "", - "pm.test(\"Response contains access point with auto-discovered boards\", function () {", + "pm.test(\"Response contains access gate with auto-discovered boards\", function () {", " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('accessPoint');", - " pm.expect(jsonData.accessPoint).to.have.property('code');", - " pm.expect(jsonData.accessPoint).to.have.property('linkedBoardsIds');", - " pm.expect(jsonData.accessPoint.linkedBoardsIds.length).to.be.at.least(1);", - " console.log(\"Auto-discovered \" + jsonData.accessPoint.linkedBoardsIds.length + \" boards\");", + " pm.expect(jsonData).to.have.property('accessGate');", + " pm.expect(jsonData.accessGate).to.have.property('code');", + " pm.expect(jsonData.accessGate).to.have.property('linkedBoardsIds');", + " pm.expect(jsonData.accessGate.linkedBoardsIds.length).to.be.at.least(1);", + " console.log(\"Auto-discovered \" + jsonData.accessGate.linkedBoardsIds.length + \" boards\");", "});", "", - "// Save the slug and access point code for later requests", + "// 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.accessPoint.code);", + " pm.collectionVariables.set(\"access_code\", jsonData.accessGate.code);", " console.log(\"Client slug saved: \" + jsonData.slug);", - " console.log(\"Access code saved: \" + jsonData.accessPoint.code);", + " console.log(\"Access code saved: \" + jsonData.accessGate.code);", "}" ], "type": "text/javascript" @@ -298,7 +298,7 @@ "response": [] }, { - "name": "Update Access Point", + "name": "Update Access Gate", "event": [ { "listen": "test", @@ -308,7 +308,7 @@ " pm.response.to.have.status(200);", "});", "", - "pm.test(\"Response contains access point data\", function () {", + "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('linkedBoardsIds');", @@ -337,17 +337,17 @@ "raw": "{}" }, "url": { - "raw": "{{url}}/admin/access-points/{{access_code}}", + "raw": "{{url}}/admin/access-gates/{{access_code}}", "host": [ "{{url}}" ], "path": [ "admin", - "access-points", + "access-gates", "{{access_code}}" ] }, - "description": "Re-runs board discovery for an access point. Optionally pass a new rootBoardId to change the root and re-discover from there. Useful when the board structure has changed." + "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": [] } @@ -381,15 +381,15 @@ " 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('accessPoints');", - " pm.expect(client.accessPoints).to.be.an('array');", + " pm.expect(client).to.have.property('accessGates');", + " pm.expect(client.accessGates).to.be.an('array');", " });", "});", "", - "// Save first access point code for testing board access", + "// Save first access gate code for testing board access", "var data = pm.response.json().data;", - "if (data.length > 0 && data[0].accessPoints.length > 0) {", - " var code = data[0].accessPoints[0].code;", + "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);", "}" From b24ca008f33c8271121ec314ab350eeef9c8939d Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 15:21:37 -0300 Subject: [PATCH 077/113] Changed endpoint to /access/:slug/:code --- api/controllers/access.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 75b7b891..22678865 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -103,15 +103,16 @@ async function getClients(req, res) { } /** - * GET /access/:code - * Gets ALL boards for an access code in a single request. + * GET /access/:slug/:code + * Gets ALL boards for a slug + access code pair in a single request. * This enables instant frontend navigation without additional requests. - * Validates code exists and client subscription is active. + * 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 getAccessBoard(req, res) { const code = req.swagger.params.code.value.toUpperCase(); + const slug = req.swagger.params.slug.value; try { const now = new Date(); @@ -128,6 +129,7 @@ async function getAccessBoard(req, res) { const client = accessGate.accessClient; if ( !client || + client.slug !== slug || !client.isActive || client.subscriptionStart > now || client.subscriptionEnd < now From f2e821a45f690546f5922e5e296b78dcee9cbce7 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 15:21:48 -0300 Subject: [PATCH 078/113] Changed endpoint to /access/:slug/:code swagger things --- api/swagger/swagger.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index fba6a988..b94deb88 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1278,12 +1278,17 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /access/{code}: + /access/{slug}/{code}: x-swagger-router-controller: access get: operationId: getAccessBoard - description: Gets ALL boards for an access code in a single request. Validates code exists and subscription is active. Returns client info, all boards array, and rootBoardId. + 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: slug + type: string + in: path + required: true + description: Client slug (institution identifier) - name: code type: string in: path From 0b7ed626936843884a4cab0cdf663b1b1b99f37d Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 20 Apr 2026 15:21:57 -0300 Subject: [PATCH 079/113] Changed endpoint to /access/:slug/:code in postman collection --- test/postman/CboardAccess.collection.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index d9900572..346c174f 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -455,16 +455,17 @@ "method": "GET", "header": [], "url": { - "raw": "{{url}}/access/{{access_code}}", + "raw": "{{url}}/access/{{client_slug}}/{{access_code}}", "host": [ "{{url}}" ], "path": [ "access", + "{{client_slug}}", "{{access_code}}" ] }, - "description": "Public endpoint that retrieves all boards for a specific access code. Used when scanning QR codes or entering access codes. No authentication required." + "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": [] } From ba64b90458f003a4a0e58ac1e060c5198cebc868 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 22:10:55 -0300 Subject: [PATCH 080/113] Refactor index on accessGateCode to use partial filter expression for better performance --- api/models/Board.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/models/Board.js b/api/models/Board.js index dcf931ca..dd021a67 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -87,8 +87,11 @@ const BOARD_SCHEMA_OPTIONS = { const boardSchema = new Schema(BOARD_SCHEMA_DEFINITION, BOARD_SCHEMA_OPTIONS); -// Sparse unique index skips documents where accessGateCode is null/missing, enforcing uniqueness only for set values -boardSchema.index({ accessGateCode: 1 }, { sparse: true, unique: true }); +// 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; From fb82e2effd160bdfc9640695e2da23540697954f Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 22:11:04 -0300 Subject: [PATCH 081/113] Increase timeout duration in beforeEach for stable test execution --- test/controllers/access.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/controllers/access.js b/test/controllers/access.js index 5f94fb7e..82ffb15c 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -26,6 +26,7 @@ describe('Access API calls', function () { }); beforeEach(async function () { + this.timeout(10000); // Create admin user adminUser = await helper.prepareUser(server, { role: 'admin', From 40a4a6c3d285c75724b83021d9439f6147fdfb63 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 23:54:51 -0300 Subject: [PATCH 082/113] Update accessGateCode assertion to check for property existence --- test/controllers/board.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/controllers/board.js b/test/controllers/board.js index a137a80c..c0536676 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -299,7 +299,8 @@ describe('Board API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.accessGateCode.should.equal(null); + res.body.should.have.property('accessGateCode'); + should.equal(res.body.accessGateCode, null); }); it('should update accessGateCode on an existing board', async function () { From 8b28a9a508bcff37ad36735cb27806d203cd75da Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 23:55:52 -0300 Subject: [PATCH 083/113] Add accessGateCode definition to board schema in Swagger documentation --- api/swagger/swagger.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index b94deb88..91dd2fce 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1885,6 +1885,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 @@ -2021,6 +2025,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 @@ -2445,6 +2453,7 @@ definitions: type: object accessGateCode: type: string + x-nullable: true description: The access gate code associated with this board AccessGateResponse: type: object From e2c2cf3dcbcd89d693f655021170d23a45bf61da Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 23:57:43 -0300 Subject: [PATCH 084/113] Enhance createMochaBoard to accept email and return it in user preparation --- test/helper.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/helper.js b/test/helper.js index a750532b..8e607bf7 100644 --- a/test/helper.js +++ b/test/helper.js @@ -434,7 +434,7 @@ async function prepareUser(server, overrides = {}) { const token = login.body.authToken; - return { token, userId }; + return { token, userId, email: data.email }; } /** @@ -479,10 +479,11 @@ async function createCommunicator(server, userToken) { * @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; } From 666806cd6fbd43381c51c74ba18b750335361807 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Mon, 20 Apr 2026 23:58:25 -0300 Subject: [PATCH 085/113] Update createMochaBoard calls to include user email and clean up test clients --- test/controllers/access.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/controllers/access.js b/test/controllers/access.js index 82ffb15c..6bca8191 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -40,8 +40,13 @@ describe('Access API calls', function () { }); // Create test boards - testBoardId = await helper.createMochaBoard(server, adminUser.token); - testBoardId2 = await helper.createMochaBoard(server, adminUser.token); + testBoardId = await helper.createMochaBoard(server, adminUser.token, adminUser.email); + testBoardId2 = await helper.createMochaBoard(server, adminUser.token, adminUser.email); + + const clients = await AccessClient.find({ 'client.name': /mocha test/i }); + const clientIds = clients.map(c => c._id); + await AccessGate.deleteMany({ accessClient: { $in: clientIds } }); + await AccessClient.deleteMany({ 'client.name': /mocha test/i }); }); after(async function () { From 6aded4e1d5df2292a663434d62ce24fc1e0503a8 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Tue, 21 Apr 2026 19:23:14 -0300 Subject: [PATCH 086/113] Refactor access control in getBoard function to simplify response handling --- api/controllers/board.js | 30 +----------------------------- test/controllers/board.js | 22 ++-------------------- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index d55f25e4..7f23d1f5 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -5,8 +5,6 @@ const { paginatedResponse } = require('../helpers/response'); const { getORQuery } = require('../helpers/query'); const Board = require('../models/Board'); const AccessGate = require('../models/AccessGate'); -const User = require('../models/User'); -const { getTokenData } = require('../helpers/auth'); const { getCbuilderBoardbyId } = require('../helpers/cbuilder'); const { processBase64Images, hasBase64Images } = require('../helpers/imageProcessor'); @@ -137,33 +135,7 @@ function getBoard(req, res) { message: 'Board does not exist. Board Id: ' + id }); } - - // If no accessGateCode, serve normally - if (!boards.accessGateCode) { - return res.status(200).json(boards.toJSON()); - } - - // Board is access-protected. Check if caller is admin by decoding the token manually — - // req.user is not populated for this public route. - let adminCheckPromise = Promise.resolve(false); - const authHeader = req.get('Authorization'); - if (authHeader) { - const tokenData = getTokenData(authHeader.split(' ')[1]); - if (tokenData?.id) { - adminCheckPromise = User.getById(tokenData.id).then(user => !!(user && user.isAdmin)); - } - } - - adminCheckPromise.then(isAdmin => { - if (!isAdmin) { - return res.status(403).json({ - message: 'This board requires an access code', - requiresAccessCode: true, - accessGate: boards.accessGateCode - }); - } - return res.status(200).json(boards.toJSON()); - }); + return res.status(200).json(boards.toJSON()); }); } diff --git a/test/controllers/board.js b/test/controllers/board.js index c0536676..0de474fb 100644 --- a/test/controllers/board.js +++ b/test/controllers/board.js @@ -337,33 +337,15 @@ describe('Board API calls', function () { const boardId = createRes.body.id; - // Direct access should be blocked with 403 for regular users const getRes = await request(server) .get('/board/' + boardId) .set('Authorization', `Bearer ${user.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) - .expect(403); - - getRes.body.should.have.property('message'); - getRes.body.message.should.equal('This board requires an access code'); - getRes.body.should.have.property('requiresAccessCode'); - getRes.body.requiresAccessCode.should.equal(true); - getRes.body.should.have.property('accessGate'); - getRes.body.accessGate.should.equal('GETTEST01'); - - // Admins should be able to access the board directly - const adminEmail = helper.generateEmail(); - const admin = await helper.prepareUser(server, { role: 'admin', email: adminEmail }); - const adminRes = await request(server) - .get('/board/' + boardId) - .set('Authorization', `Bearer ${admin.token}`) - .set('Accept', 'application/json') - .expect('Content-Type', /json/) .expect(200); - adminRes.body.should.have.property('accessGateCode'); - adminRes.body.accessGateCode.should.equal('GETTEST01'); + getRes.body.should.have.property('accessGateCode'); + getRes.body.accessGateCode.should.equal('GETTEST01'); }); it('should exclude boards with accessGate from public boards listing', async function () { From 6fedd81477fc740d8de3f4a6e11f964511f0ff6e Mon Sep 17 00:00:00 2001 From: Agus Date: Wed, 22 Apr 2026 13:13:29 -0300 Subject: [PATCH 087/113] Don't save duplicated access clients --- api/controllers/access.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/controllers/access.js b/api/controllers/access.js index 22678865..6cebdc9b 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -217,6 +217,18 @@ async function createAccessClient(req, res) { 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: accessGate.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, From 0edd921d082a79cdfcacaa9f609330183743056c Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 16:46:46 -0300 Subject: [PATCH 088/113] =?UTF-8?q?Rename=20linkedBoardsIds=20=E2=86=92=20?= =?UTF-8?q?linkedBoardIds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix plural placement: the field holds IDs of linked boards, so the plural belongs to "boards", not "IDs". Co-Authored-By: Claude Sonnet 4.6 --- api/controllers/access.js | 16 ++++++++-------- api/controllers/board.js | 4 ++-- api/models/AccessGate.js | 2 +- api/swagger/swagger.yaml | 4 ++-- test/controllers/access.js | 8 ++++---- test/postman/CboardAccess.README.md | 4 ++-- test/postman/CboardAccess.collection.json | 8 ++++---- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 6cebdc9b..272b8f15 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -141,7 +141,7 @@ async function getAccessBoard(req, res) { // Fetch all linked boards (exclude PII fields for public endpoint) const boards = await Board.find({ - _id: { $in: accessGate.linkedBoardsIds } + _id: { $in: accessGate.linkedBoardIds } }).select('-email -author'); if (!boards || boards.length === 0) { @@ -249,7 +249,7 @@ async function createAccessClient(req, res) { code: accessGate.toUpperCase(), accessClient: client._id, rootBoardId, - linkedBoardsIds: linkedBoardIds + linkedBoardIds: linkedBoardIds }); await newAccessGate.save(); @@ -279,7 +279,7 @@ async function createAccessClient(req, res) { * 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.linkedBoardsIds. + * Populates createdBy. Board count derived from AccessGate.linkedBoardIds. */ async function listAccessClients(req, res) { try { @@ -287,14 +287,14 @@ async function listAccessClients(req, res) { .populate('createdBy', 'name email role') .sort({ createdAt: -1 }); - // Fetch access gates and sum linkedBoardsIds per client to avoid N+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({ accessClient: { $in: clientIds } }); const agMap = accessGates.reduce((map, ag) => { const key = ag.accessClient.toString(); if (!map[key]) map[key] = 0; - map[key] += ag.linkedBoardsIds?.length || 0; + map[key] += ag.linkedBoardIds?.length || 0; return map; }, {}); @@ -382,7 +382,7 @@ async function getAccessClientStats(req, res) { // Collect unique board IDs across all access gates const allBoardIds = [ - ...new Set(accessGates.flatMap(ag => ag.linkedBoardsIds.map(id => id.toString()))) + ...new Set(accessGates.flatMap(ag => ag.linkedBoardIds.map(id => id.toString()))) ]; const boards = await Board.find({ _id: { $in: allBoardIds } }, { name: 1, tiles: 1 }); @@ -423,7 +423,7 @@ async function getAccessClientStats(req, res) { /** * PUT /admin/access-gates/:code - * Re-runs board discovery for an access gate, updating its linkedBoardsIds. + * 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. */ @@ -449,7 +449,7 @@ async function updateAccessGate(req, res) { const linkedBoardIds = await getAllLinkedBoardIds(newRootBoardId); accessGate.rootBoardId = newRootBoardId; - accessGate.linkedBoardsIds = linkedBoardIds; + accessGate.linkedBoardIds = linkedBoardIds; await accessGate.save(); // Mark all discovered boards with this access gate code diff --git a/api/controllers/board.js b/api/controllers/board.js index 7f23d1f5..3371330c 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -104,8 +104,8 @@ async function deleteBoard(req, res) { }); } await AccessGate.updateMany( - { linkedBoardsIds: id }, - { $pull: { linkedBoardsIds: id } } + { linkedBoardIds: id }, + { $pull: { linkedBoardIds: id } } ); await AccessGate.updateMany( { rootBoardId: id }, diff --git a/api/models/AccessGate.js b/api/models/AccessGate.js index f125fb9b..2e7c7216 100644 --- a/api/models/AccessGate.js +++ b/api/models/AccessGate.js @@ -30,7 +30,7 @@ const ACCESS_GATE_SCHEMA_DEFINITION = { type: Date, default: null }, - linkedBoardsIds: { + linkedBoardIds: { type: [Schema.Types.ObjectId], ref: 'Board', default: [] diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 91dd2fce..1f3c0d10 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1450,7 +1450,7 @@ paths: x-swagger-router-controller: access put: operationId: updateAccessGate - description: Re-runs board discovery for an access gate, updating its linkedBoardsIds. Optionally accepts a new rootBoardId. Requires admin authentication. + 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: @@ -2469,7 +2469,7 @@ definitions: rootBoardId: type: string description: Root board ID - linkedBoardsIds: + linkedBoardIds: type: array items: type: string diff --git a/test/controllers/access.js b/test/controllers/access.js index 6bca8191..68dd5f02 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -88,7 +88,7 @@ describe('Access API calls', function () { 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.linkedBoardsIds.length.should.eql(1); + res.body.accessGate.linkedBoardIds.length.should.eql(1); // Verify board was marked with accessGate const board = await Board.findById(testBoardId); @@ -357,8 +357,8 @@ describe('Access API calls', function () { .expect(200); res.body.should.have.property('code').eql('APUPDATE01'); - res.body.should.have.property('linkedBoardsIds').that.is.an('array'); - res.body.linkedBoardsIds.should.include(testBoardId2.toString()); + 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); @@ -375,7 +375,7 @@ describe('Access API calls', function () { .expect(200); res.body.should.have.property('code').eql('APUPDATE01'); - res.body.linkedBoardsIds.should.include(testBoardId.toString()); + res.body.linkedBoardIds.should.include(testBoardId.toString()); }); it('it should return 404 for non-existent access gate code', async function () { diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index d4cf2c38..c3caed75 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -88,7 +88,7 @@ The collection includes these variables (can be edited in collection variables o } ``` -2. The response includes the created client (`slug`, `client.name`, etc.) and the `accessGate` with `code` and `linkedBoardsIds` +2. The response includes the created client (`slug`, `client.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 @@ -179,7 +179,7 @@ Each request includes automated tests: 1. Update boards (add/remove tile navigation) 2. Run "Update Access Gate" to re-discover linked boards -3. Verify `linkedBoardsIds` count updated +3. Verify `linkedBoardIds` count updated ## Troubleshooting diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 346c174f..e026b55d 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -32,9 +32,9 @@ " 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('linkedBoardsIds');", - " pm.expect(jsonData.accessGate.linkedBoardsIds.length).to.be.at.least(1);", - " console.log(\"Auto-discovered \" + jsonData.accessGate.linkedBoardsIds.length + \" boards\");", + " 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", @@ -311,7 +311,7 @@ "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('linkedBoardsIds');", + " pm.expect(jsonData).to.have.property('linkedBoardIds');", " pm.expect(jsonData).to.have.property('rootBoardId');", "});" ], From 5ad571d4a25fae714245d10ee03c6705d6528a4d Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 16:48:28 -0300 Subject: [PATCH 089/113] =?UTF-8?q?Rename=20AccessClient.client=20subdoc?= =?UTF-8?q?=20=E2=86=92=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nested field was named client inside the AccessClient model, making references like client.client.name awkward to read. Renaming to contact removes the duplication. Co-Authored-By: Claude Sonnet 4.6 --- api/controllers/access.js | 14 +++++++------- api/models/AccessClient.js | 2 +- api/swagger/swagger.yaml | 4 ++-- test/controllers/access.js | 14 +++++++------- test/postman/CboardAccess.README.md | 2 +- test/postman/CboardAccess.collection.json | 8 ++++---- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 272b8f15..6577dd6b 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -61,7 +61,7 @@ async function getClients(req, res) { isActive: true, subscriptionStart: { $lte: now }, subscriptionEnd: { $gte: now } - }).select('slug client brandColor'); + }).select('slug contact brandColor'); const accessGates = await AccessGate.find({ accessClient: { $in: clients.map(c => c._id) } @@ -72,7 +72,7 @@ async function getClients(req, res) { const result = clients .map(client => ({ slug: client.slug, - clientName: client.client.name, + clientName: client.contact.name, brandColor: client.brandColor, accessGates: accessGates .filter(ag => ag.accessClient.toString() === client._id.toString()) @@ -172,7 +172,7 @@ async function getAccessBoard(req, res) { return res.status(200).json({ client: { code: client.slug, - name: client.client.name, + name: client.contact.name, color: client.brandColor }, boards: boards.map(b => b.toJSON()), @@ -232,7 +232,7 @@ async function createAccessClient(req, res) { // Create the client const client = new AccessClient({ slug, - client: { name: clientName, email: clientEmail, phone: clientPhone }, + contact: { name: clientName, email: clientEmail, phone: clientPhone }, brandColor, subscriptionStart: new Date(subscriptionStart), subscriptionEnd: new Date(subscriptionEnd), @@ -346,9 +346,9 @@ async function updateAccessClient(req, res) { client.subscriptionStart = new Date(updates.subscriptionStart); if (updates.subscriptionEnd) client.subscriptionEnd = new Date(updates.subscriptionEnd); - if (updates.clientName) client.client.name = updates.clientName; - if (updates.clientEmail) client.client.email = updates.clientEmail; - if (updates.clientPhone) client.client.phone = updates.clientPhone; + 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(); diff --git a/api/models/AccessClient.js b/api/models/AccessClient.js index 9f1ef178..2ba8e4d2 100644 --- a/api/models/AccessClient.js +++ b/api/models/AccessClient.js @@ -11,7 +11,7 @@ const ACCESS_CLIENT_SCHEMA_DEFINITION = { trim: true, lowercase: true }, - client: { + contact: { name: { type: String, required: true, diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 1f3c0d10..c61444b6 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2251,7 +2251,7 @@ definitions: slug: type: string description: Unique slug identifier (lowercase) - client: + contact: type: object properties: name: @@ -2351,7 +2351,7 @@ definitions: type: string slug: type: string - client: + contact: type: object properties: name: diff --git a/test/controllers/access.js b/test/controllers/access.js index 68dd5f02..1e73e7ac 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -43,20 +43,20 @@ describe('Access API calls', function () { testBoardId = await helper.createMochaBoard(server, adminUser.token, adminUser.email); testBoardId2 = await helper.createMochaBoard(server, adminUser.token, adminUser.email); - const clients = await AccessClient.find({ 'client.name': /mocha test/i }); + const clients = await AccessClient.find({ 'contact.name': /mocha test/i }); const clientIds = clients.map(c => c._id); await AccessGate.deleteMany({ accessClient: { $in: clientIds } }); - await AccessClient.deleteMany({ 'client.name': /mocha test/i }); + 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({ 'client.name': /mocha test/i }); + const clients = await AccessClient.find({ 'contact.name': /mocha test/i }); const clientIds = clients.map(c => c._id); await AccessGate.deleteMany({ accessClient: { $in: clientIds } }); - await AccessClient.deleteMany({ 'client.name': /mocha test/i }); + await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); }); describe('POST /admin/access-clients', function () { @@ -82,7 +82,7 @@ describe('Access API calls', function () { res.body.should.be.a('object'); res.body.should.have.property('slug').eql('test-01'); - res.body.client.should.have.property('name').eql('Test Client mocha test'); + 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'); @@ -287,7 +287,7 @@ describe('Access API calls', function () { .expect('Content-Type', /json/) .expect(200); - res.body.client.should.have.property('name').eql('Updated Name mocha test'); + 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); }); @@ -429,7 +429,7 @@ describe('Access API calls', function () { // Verify client data res.body.client.should.have.property('slug').eql('stats-01'); - res.body.client.client.should.have.property('name').eql('Stats Test mocha test'); + 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); diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index c3caed75..d46eefcd 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -88,7 +88,7 @@ The collection includes these variables (can be edited in collection variables o } ``` -2. The response includes the created client (`slug`, `client.name`, etc.) and the `accessGate` with `code` and `linkedBoardIds` +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 diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index e026b55d..7c55fde1 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -24,8 +24,8 @@ "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('client');", - " pm.expect(jsonData.client).to.have.property('name');", + " 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 () {", @@ -104,8 +104,8 @@ " if (jsonData.data.length > 0) {", " var client = jsonData.data[0];", " pm.expect(client).to.have.property('slug');", - " pm.expect(client).to.have.property('client');", - " pm.expect(client.client).to.have.property('name');", + " 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');", " }", From 58822f522692bb1073686ce0f7edf7654d896957 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 16:48:52 -0300 Subject: [PATCH 090/113] =?UTF-8?q?Rename=20request=20body=20field=20acces?= =?UTF-8?q?sGate=20=E2=86=92=20accessGateCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create endpoint accepted an accessGate field whose value was a code string, making it easy to confuse with the full AccessGate object returned in the response. Renaming the input field to accessGateCode makes the distinction explicit. Co-Authored-By: Claude Sonnet 4.6 --- api/controllers/access.js | 6 +++--- api/swagger/swagger.yaml | 4 ++-- test/controllers/access.js | 24 +++++++++++------------ test/postman/CboardAccess.README.md | 4 ++-- test/postman/CboardAccess.collection.json | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 6577dd6b..9d23a001 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -207,7 +207,7 @@ async function createAccessClient(req, res) { rootBoardId, subscriptionStart, subscriptionEnd, - accessGate + accessGateCode } = req.body; try { @@ -220,7 +220,7 @@ async function createAccessClient(req, res) { // Check for duplicates before persisting anything to avoid orphaned documents const [existingSlug, existingCode] = await Promise.all([ AccessClient.findOne({ slug }), - AccessGate.findOne({ code: accessGate.toUpperCase() }) + AccessGate.findOne({ code: accessGateCode.toUpperCase() }) ]); if (existingSlug) { return res.status(409).json({ message: 'Slug already exists' }); @@ -246,7 +246,7 @@ async function createAccessClient(req, res) { // Create the access gate const newAccessGate = new AccessGate({ - code: accessGate.toUpperCase(), + code: accessGateCode.toUpperCase(), accessClient: client._id, rootBoardId, linkedBoardIds: linkedBoardIds diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index c61444b6..07721cbf 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2181,13 +2181,13 @@ definitions: - rootBoardId - subscriptionStart - subscriptionEnd - - accessGate + - accessGateCode properties: slug: type: string description: Unique slug identifier for the access client (lowercase) example: my-company - accessGate: + accessGateCode: type: string description: Unique code for the access gate (will be uppercased) example: ABC123 diff --git a/test/controllers/access.js b/test/controllers/access.js index 1e73e7ac..94a25922 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -67,7 +67,7 @@ describe('Access API calls', function () { clientContact: 'test@example.com', brandColor: '#FF5733', rootBoardId: testBoardId, - accessGate: 'TEST01', + accessGateCode: 'TEST01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -100,7 +100,7 @@ describe('Access API calls', function () { slug: 'duplicate', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessGate: 'DUPL01', + accessGateCode: 'DUPL01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -115,7 +115,7 @@ describe('Access API calls', function () { // Try to create with same slug const res = await request(server) .post('/admin/access-clients') - .send({ ...clientData, accessGate: 'DUPL02' }) + .send({ ...clientData, accessGateCode: 'DUPL02' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -129,7 +129,7 @@ describe('Access API calls', function () { slug: 'duplicate-code-a', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessGate: 'DUPCODE', + accessGateCode: 'DUPCODE', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -158,7 +158,7 @@ describe('Access API calls', function () { slug: 'test-04', clientName: 'Test Client mocha test', rootBoardId: '507f1f77bcf86cd799439011', // Non-existent ID - accessGate: 'TEST04', + accessGateCode: 'TEST04', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -179,7 +179,7 @@ describe('Access API calls', function () { slug: 'test-05', clientName: 'Test Client mocha test', rootBoardId: testBoardId, - accessGate: 'TEST05', + accessGateCode: 'TEST05', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }; @@ -202,7 +202,7 @@ describe('Access API calls', function () { slug: 'list-01', clientName: 'List Test 1 mocha test', rootBoardId: testBoardId, - accessGate: 'LIST01', + accessGateCode: 'LIST01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }) @@ -214,7 +214,7 @@ describe('Access API calls', function () { slug: 'list-02', clientName: 'List Test 2 mocha test', rootBoardId: testBoardId, - accessGate: 'LIST02', + accessGateCode: 'LIST02', subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), // Expired }) @@ -264,7 +264,7 @@ describe('Access API calls', function () { slug: 'update-01', clientName: 'Update Test mocha test', rootBoardId: testBoardId, - accessGate: 'UPDATE01', + accessGateCode: 'UPDATE01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), brandColor: '#000000', @@ -340,7 +340,7 @@ describe('Access API calls', function () { slug: 'ap-update-01', clientName: 'Access Gate Update Test mocha test', rootBoardId: testBoardId, - accessGate: 'APUPDATE01', + accessGateCode: 'APUPDATE01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }) @@ -408,7 +408,7 @@ describe('Access API calls', function () { slug: 'stats-01', clientName: 'Stats Test mocha test', rootBoardId: testBoardId, - accessGate: 'STATS01', + accessGateCode: 'STATS01', subscriptionStart: new Date(), subscriptionEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), }) @@ -454,7 +454,7 @@ describe('Access API calls', function () { slug: 'expired-01', clientName: 'Expired Test mocha test', rootBoardId: testBoardId, - accessGate: 'EXPIRED01', + accessGateCode: 'EXPIRED01', subscriptionStart: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), subscriptionEnd: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), }) diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index d46eefcd..05e1566a 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -82,7 +82,7 @@ The collection includes these variables (can be edited in collection variables o "clientPhone": "+1234567890", "brandColor": "#8B4513", "rootBoardId": "{{root_board_id}}", - "accessGate": "CAFE01", + "accessGateCode": "CAFE01", "subscriptionStart": "2026-04-01T00:00:00.000Z", "subscriptionEnd": "2027-04-01T00:00:00.000Z" } @@ -115,7 +115,7 @@ Use **Update Access Gate** to re-run board discovery after the board structure c - `slug`: Unique client identifier (lowercase, URL-safe) - `clientName`: Display name - `rootBoardId`: ID of the main board -- `accessGate`: Unique code for the access gate (uppercase alphanumeric) +- `accessGateCode`: Unique code for the access gate (uppercase alphanumeric) - `subscriptionStart`: Start date (ISO 8601) - `subscriptionEnd`: End date (ISO 8601) diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 7c55fde1..222eedc8 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -65,7 +65,7 @@ ], "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 \"accessGate\": \"TEST01\",\n \"subscriptionStart\": \"2026-04-01T00:00:00.000Z\",\n \"subscriptionEnd\": \"2027-04-01T00:00:00.000Z\"\n}" + "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", From 8c51c2a1776be53957262a6dcd25f24f55d70fbc Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 18:44:08 -0300 Subject: [PATCH 091/113] Refactor access API endpoints and update related tests for consistency --- api/controllers/access.js | 22 ++++---- api/swagger/swagger.yaml | 22 ++++---- test/controllers/access.js | 64 +++++++++++------------ test/postman/CboardAccess.README.md | 4 +- test/postman/CboardAccess.collection.json | 30 ++++++----- 5 files changed, 74 insertions(+), 68 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 9d23a001..fae625f1 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -103,7 +103,7 @@ async function getClients(req, res) { } /** - * GET /access/:slug/:code + * 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. @@ -111,8 +111,8 @@ async function getClients(req, res) { * Increments viewsCount and updates lastAccessAt on the AccessGate. */ async function getAccessBoard(req, res) { - const code = req.swagger.params.code.value.toUpperCase(); - const slug = req.swagger.params.slug.value; + const code = req.swagger.params.gateCode.value.toUpperCase(); + const slug = req.swagger.params.clientSlug.value; try { const now = new Date(); @@ -191,7 +191,7 @@ async function getAccessBoard(req, res) { // ============================================ /** - * POST /admin/access-clients + * 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. @@ -276,7 +276,7 @@ async function createAccessClient(req, res) { } /** - * GET /admin/access-clients + * 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. @@ -326,12 +326,12 @@ async function listAccessClients(req, res) { } /** - * PUT /admin/access-clients/:slug + * 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.slug.value; + const slug = req.swagger.params.clientSlug.value; const updates = req.body; try { @@ -363,12 +363,12 @@ async function updateAccessClient(req, res) { } /** - * GET /admin/access-clients/:slug/stats + * 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.slug.value; + const slug = req.swagger.params.clientSlug.value; try { const client = await AccessClient.findOne({ slug }) @@ -422,13 +422,13 @@ async function getAccessClientStats(req, res) { } /** - * PUT /admin/access-gates/:code + * 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.code.value.toUpperCase(); + const code = req.swagger.params.gateCode.value.toUpperCase(); const { rootBoardId } = req.body; try { diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 07721cbf..2124362f 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1278,22 +1278,22 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /access/{slug}/{code}: + /access/{clientSlug}/{gateCode}: x-swagger-router-controller: access get: operationId: getAccessBoard 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: slug + - name: clientSlug type: string in: path required: true description: Client slug (institution identifier) - - name: code + - name: gateCode type: string in: path required: true - description: Access code for the client + description: Access gate code for the client responses: "200": description: Success @@ -1307,7 +1307,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-clients: + /admin/access/clients: x-swagger-router-controller: access post: operationId: createAccessClient @@ -1356,7 +1356,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-clients/{slug}: + /admin/access/clients/{clientSlug}: x-swagger-router-controller: access put: operationId: updateAccessClient @@ -1366,7 +1366,7 @@ paths: x-security-scopes: - admin parameters: - - name: slug + - name: clientSlug type: string in: path required: true @@ -1390,7 +1390,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-clients/{slug}/stats: + /admin/access/clients/{clientSlug}/stats: x-swagger-router-controller: access get: operationId: getAccessClientStats @@ -1400,7 +1400,7 @@ paths: x-security-scopes: - admin parameters: - - name: slug + - name: clientSlug type: string in: path required: true @@ -1446,7 +1446,7 @@ paths: description: Error schema: $ref: "#/definitions/ErrorResponse" - /admin/access-gates/{code}: + /admin/access/gates/{gateCode}: x-swagger-router-controller: access put: operationId: updateAccessGate @@ -1456,7 +1456,7 @@ paths: x-security-scopes: - admin parameters: - - name: code + - name: gateCode type: string in: path required: true diff --git a/test/controllers/access.js b/test/controllers/access.js index 94a25922..69143c18 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -59,7 +59,7 @@ describe('Access API calls', function () { await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); }); - describe('POST /admin/access-clients', function () { + 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', @@ -73,7 +73,7 @@ describe('Access API calls', function () { }; const res = await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send(clientData) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -107,14 +107,14 @@ describe('Access API calls', function () { // Create first client await request(server) - .post('/admin/access-clients') + .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') + .post('/admin/access/clients') .send({ ...clientData, accessGateCode: 'DUPL02' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -136,14 +136,14 @@ describe('Access API calls', function () { // Create first client await request(server) - .post('/admin/access-clients') + .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') + .post('/admin/access/clients') .send({ ...clientData, slug: 'duplicate-code-b' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -164,7 +164,7 @@ describe('Access API calls', function () { }; const res = await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send(clientData) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -185,7 +185,7 @@ describe('Access API calls', function () { }; await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send(clientData) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -193,11 +193,11 @@ describe('Access API calls', function () { }); }); - describe('GET /admin/access-clients', function () { + describe('GET /admin/access/clients', function () { beforeEach(async function () { // Create test clients await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'list-01', clientName: 'List Test 1 mocha test', @@ -209,7 +209,7 @@ describe('Access API calls', function () { .set('Authorization', `Bearer ${adminUser.token}`); await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'list-02', clientName: 'List Test 2 mocha test', @@ -223,7 +223,7 @@ describe('Access API calls', function () { it('it should LIST all access clients with stats', async function () { const res = await request(server) - .get('/admin/access-clients') + .get('/admin/access/clients') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -249,17 +249,17 @@ describe('Access API calls', function () { it('it should NOT LIST without authorization', async function () { await request(server) - .get('/admin/access-clients') + .get('/admin/access/clients') .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(403); }); }); - describe('PUT /admin/access-clients/:slug', function () { + describe('PUT /admin/access/clients/:slug', function () { beforeEach(async function () { await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'update-01', clientName: 'Update Test mocha test', @@ -280,7 +280,7 @@ describe('Access API calls', function () { }; const res = await request(server) - .put('/admin/access-clients/update-01') + .put('/admin/access/clients/update-01') .send(updates) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -299,7 +299,7 @@ describe('Access API calls', function () { }; const res = await request(server) - .put('/admin/access-clients/update-01') + .put('/admin/access/clients/update-01') .send(updates) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -312,7 +312,7 @@ describe('Access API calls', function () { it('it should return 404 for non-existent slug', async function () { const res = await request(server) - .put('/admin/access-clients/nonexistent') + .put('/admin/access/clients/nonexistent') .send({ clientName: 'Test' }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -324,7 +324,7 @@ describe('Access API calls', function () { it('it should NOT UPDATE without authorization', async function () { await request(server) - .put('/admin/access-clients/update-01') + .put('/admin/access/clients/update-01') .send({ clientName: 'Test' }) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -332,10 +332,10 @@ describe('Access API calls', function () { }); }); - describe('PUT /admin/access-gates/:code', function () { + describe('PUT /admin/access/gates/:code', function () { beforeEach(async function () { await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'ap-update-01', clientName: 'Access Gate Update Test mocha test', @@ -349,7 +349,7 @@ describe('Access API calls', function () { it('it should re-discover boards when updating access gate root', async function () { const res = await request(server) - .put('/admin/access-gates/APUPDATE01') + .put('/admin/access/gates/APUPDATE01') .send({ rootBoardId: testBoardId2 }) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -367,7 +367,7 @@ describe('Access API calls', function () { it('it should re-discover without changing root when no rootBoardId provided', async function () { const res = await request(server) - .put('/admin/access-gates/APUPDATE01') + .put('/admin/access/gates/APUPDATE01') .send({}) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -380,7 +380,7 @@ describe('Access API calls', function () { it('it should return 404 for non-existent access gate code', async function () { const res = await request(server) - .put('/admin/access-gates/NONEXISTENT') + .put('/admin/access/gates/NONEXISTENT') .send({}) .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') @@ -392,7 +392,7 @@ describe('Access API calls', function () { it('it should NOT UPDATE without authorization', async function () { await request(server) - .put('/admin/access-gates/APUPDATE01') + .put('/admin/access/gates/APUPDATE01') .send({}) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -400,10 +400,10 @@ describe('Access API calls', function () { }); }); - describe('GET /admin/access-clients/:slug/stats', function () { + describe('GET /admin/access/clients/:slug/stats', function () { beforeEach(async function () { await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'stats-01', clientName: 'Stats Test mocha test', @@ -417,7 +417,7 @@ describe('Access API calls', function () { it('it should GET client statistics', async function () { const res = await request(server) - .get('/admin/access-clients/stats-01/stats') + .get('/admin/access/clients/stats-01/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -449,7 +449,7 @@ describe('Access API calls', function () { it('it should return 0 daysUntilExpiry for expired client', async function () { // Create expired client await request(server) - .post('/admin/access-clients') + .post('/admin/access/clients') .send({ slug: 'expired-01', clientName: 'Expired Test mocha test', @@ -461,7 +461,7 @@ describe('Access API calls', function () { .set('Authorization', `Bearer ${adminUser.token}`); const res = await request(server) - .get('/admin/access-clients/expired-01/stats') + .get('/admin/access/clients/expired-01/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -473,7 +473,7 @@ describe('Access API calls', function () { it('it should return 404 for non-existent slug', async function () { const res = await request(server) - .get('/admin/access-clients/nonexistent/stats') + .get('/admin/access/clients/nonexistent/stats') .set('Authorization', `Bearer ${adminUser.token}`) .set('Accept', 'application/json') .expect('Content-Type', /json/) @@ -484,7 +484,7 @@ describe('Access API calls', function () { it('it should NOT GET stats without authorization', async function () { await request(server) - .get('/admin/access-clients/stats-01/stats') + .get('/admin/access/clients/stats-01/stats') .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(403); diff --git a/test/postman/CboardAccess.README.md b/test/postman/CboardAccess.README.md index 05e1566a..0515bae6 100644 --- a/test/postman/CboardAccess.README.md +++ b/test/postman/CboardAccess.README.md @@ -141,8 +141,8 @@ Board discovery is automatic — all boards reachable from `rootBoardId` via til These endpoints don't require authentication and simulate real user access: -- **GET /access/clients**: Returns only active clients with valid subscriptions -- **GET /access/:code**: Returns all boards for the access code and increments access counter +- **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 diff --git a/test/postman/CboardAccess.collection.json b/test/postman/CboardAccess.collection.json index 222eedc8..8a029183 100644 --- a/test/postman/CboardAccess.collection.json +++ b/test/postman/CboardAccess.collection.json @@ -68,13 +68,14 @@ "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", + "raw": "{{url}}/admin/access/clients", "host": [ "{{url}}" ], "path": [ "admin", - "access-clients" + "access", + "clients" ] }, "description": "Creates a new Cboard Access client with the specified configuration. Requires admin authentication." @@ -125,13 +126,14 @@ } ], "url": { - "raw": "{{url}}/admin/access-clients", + "raw": "{{url}}/admin/access/clients", "host": [ "{{url}}" ], "path": [ "admin", - "access-clients" + "access", + "clients" ] }, "description": "Lists all Cboard Access clients with their statistics. Requires admin authentication." @@ -176,13 +178,14 @@ "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}}", + "raw": "{{url}}/admin/access/clients/{{client_slug}}", "host": [ "{{url}}" ], "path": [ "admin", - "access-clients", + "access", + "clients", "{{client_slug}}" ] }, @@ -228,13 +231,14 @@ "raw": "{\n \"isActive\": false\n}" }, "url": { - "raw": "{{url}}/admin/access-clients/{{client_slug}}", + "raw": "{{url}}/admin/access/clients/{{client_slug}}", "host": [ "{{url}}" ], "path": [ "admin", - "access-clients", + "access", + "clients", "{{client_slug}}" ] }, @@ -282,13 +286,14 @@ } ], "url": { - "raw": "{{url}}/admin/access-clients/{{client_slug}}/stats", + "raw": "{{url}}/admin/access/clients/{{client_slug}}/stats", "host": [ "{{url}}" ], "path": [ "admin", - "access-clients", + "access", + "clients", "{{client_slug}}", "stats" ] @@ -337,13 +342,14 @@ "raw": "{}" }, "url": { - "raw": "{{url}}/admin/access-gates/{{access_code}}", + "raw": "{{url}}/admin/access/gates/{{access_code}}", "host": [ "{{url}}" ], "path": [ "admin", - "access-gates", + "access", + "gates", "{{access_code}}" ] }, From 8115aceab577e9166440ade8dbc41b525f989e02 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 19:46:00 -0300 Subject: [PATCH 092/113] Remove qrcode dependency from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index dd82d05e..48a09df6 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "passport-facebook-token": "^4.0.0", "passport-google-oauth": "^2.0.0", "passport-google-token": "^0.1.2", - "qrcode": "^1.5.4", "pem": "^1.14.8", "rand-token": "0.4.0", "should": "^13.2.3", From 49cab908785561cca4a3256b74252c14c86c3834 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 22 Apr 2026 19:46:10 -0300 Subject: [PATCH 093/113] Refactor code structure for improved readability and maintainability --- scripts/generate-qr.js | 79 -- scripts/qr-generator/generate-qr.js | 126 +++ scripts/qr-generator/qrcodegen-v1.8.0-es6.js | 837 +++++++++++++++++++ 3 files changed, 963 insertions(+), 79 deletions(-) delete mode 100644 scripts/generate-qr.js create mode 100644 scripts/qr-generator/generate-qr.js create mode 100644 scripts/qr-generator/qrcodegen-v1.8.0-es6.js diff --git a/scripts/generate-qr.js b/scripts/generate-qr.js deleted file mode 100644 index 3def1e0f..00000000 --- a/scripts/generate-qr.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -const QRCode = require('qrcode'); -const path = require('path'); - -require('dotenv').config(); - -const DEFAULT_BASE_URL = process.env.CBOARD_APP_URL || 'https://app.cboard.io'; -const QR_WIDTH = 500; - -/** - * Generates a QR code PNG for a Cboard Access code. - * - * Creates a QR code that links to the access URL for the given code. - * The QR code can be printed and displayed at client locations. - * - * @param {string} code - The access code (e.g., "CAFE01") - * @param {string} baseUrl - Base URL for the access link (default: CBOARD_APP_URL or https://app.cboard.io) - * @returns {Promise<{url: string, outputPath: string}>} - The encoded URL and output file path - * @throws {Error} If code is not provided or QR generation fails - * - * @example - * // CLI usage: - * // node scripts/generate-qr.js CAFE01 - * // node scripts/generate-qr.js CAFE01 https://app.cboard.io - * // node scripts/generate-qr.js TEST01 http://localhost:3000 - * - * // Programmatic usage: - * const { url, outputPath } = await generateQRCode('CAFE01', 'https://app.cboard.io'); - * // Creates: CAFE01-qr.png in current directory - */ -async function generateQRCode(code, baseUrl = DEFAULT_BASE_URL) { - if (!code) { - throw new Error('Access code is required'); - } - - const normalizedCode = code.toUpperCase(); - const url = `${baseUrl}/access/${normalizedCode}`; - const filename = `${normalizedCode}-qr.png`; - const outputPath = path.join(process.cwd(), filename); - - await QRCode.toFile(outputPath, url, { - width: QR_WIDTH, - margin: 2, - errorCorrectionLevel: 'M', - color: { - dark: '#000000', - light: '#ffffff' - } - }); - - return { url, outputPath }; -} - -if (require.main === module) { - const args = process.argv.slice(2); - const code = args[0]; - const baseUrl = args[1] || DEFAULT_BASE_URL; - - if (!code) { - console.error('Error: Access code is required'); - console.error('\nUsage: node scripts/generate-qr.js [BASE_URL]'); - console.error('Example: node scripts/generate-qr.js CAFE01 https://app.cboard.io'); - process.exit(1); - } - - generateQRCode(code, 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/generate-qr.js b/scripts/qr-generator/generate-qr.js new file mode 100644 index 00000000..91d45ab3 --- /dev/null +++ b/scripts/qr-generator/generate-qr.js @@ -0,0 +1,126 @@ +'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, baseUrl = DEFAULT_BASE_URL) { + if (!code) throw new Error('Access code is required'); + + const normalizedCode = code.toUpperCase(); + const url = `${baseUrl}/access/${normalizedCode}`; + const filename = `${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 code = args[0]; + const baseUrl = args[1] || DEFAULT_BASE_URL; + + if (!code) { + console.error('Error: Access code is required'); + console.error('\nUsage: node scripts/qr-generator/generate-qr.js [BASE_URL]'); + console.error('Example: node scripts/qr-generator/generate-qr.js CAFE01 https://app.cboard.io'); + process.exit(1); + } + + generateQRCode(code, 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 = {})); From 9a0aba162a27ef745464fea327ea289fa2d2c576 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:27:43 -0300 Subject: [PATCH 094/113] Update QR code generation to include client slug in URL and filename --- scripts/qr-generator/generate-qr.js | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/qr-generator/generate-qr.js b/scripts/qr-generator/generate-qr.js index 91d45ab3..95387342 100644 --- a/scripts/qr-generator/generate-qr.js +++ b/scripts/qr-generator/generate-qr.js @@ -71,12 +71,13 @@ function encodeGrayscalePng(pixels, width, height) { ]); } -function generateQRCode(code, baseUrl = DEFAULT_BASE_URL) { +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/${normalizedCode}`; - const filename = `${normalizedCode}-qr.png`; + 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); @@ -101,17 +102,18 @@ function generateQRCode(code, baseUrl = DEFAULT_BASE_URL) { if (require.main === module) { const args = process.argv.slice(2); - const code = args[0]; - const baseUrl = args[1] || DEFAULT_BASE_URL; - - if (!code) { - console.error('Error: Access code is required'); - console.error('\nUsage: node scripts/qr-generator/generate-qr.js [BASE_URL]'); - console.error('Example: node scripts/qr-generator/generate-qr.js CAFE01 https://app.cboard.io'); + 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, baseUrl) + generateQRCode(code, slug, baseUrl) .then(({ url, outputPath }) => { console.log('QR code generated successfully!'); console.log(' URL encoded:', url); From b9922fd00e34bd4179b9d3714b63822c92a61328 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:28:30 -0300 Subject: [PATCH 095/113] Implement logic to clear accessGateCode from boards no longer linked after root change --- api/controllers/access.js | 11 ++++++++++- test/controllers/access.js | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index fae625f1..44239985 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -445,14 +445,23 @@ async function updateAccessGate(req, res) { 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(); - // Mark all discovered boards with this access gate code + if (removedIds.length) { + await Board.updateMany( + { _id: { $in: removedIds } }, + { $set: { accessGateCode: null } } + ); + } await Board.updateMany( { _id: { $in: linkedBoardIds } }, { $set: { accessGateCode: code } } diff --git a/test/controllers/access.js b/test/controllers/access.js index 69143c18..4ecc058b 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -365,6 +365,25 @@ describe('Access API calls', function () { 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') From d3f1257de499b61565b2382254aa12822ce0de56 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:29:11 -0300 Subject: [PATCH 096/113] Add error handling to createAccessClient for linked board discovery --- api/controllers/access.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/controllers/access.js b/api/controllers/access.js index 44239985..08c5e8ab 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -241,6 +241,7 @@ async function createAccessClient(req, res) { await client.save(); + try{ // Auto-discover all boards reachable from root via tile.loadBoard links const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); @@ -259,6 +260,10 @@ async function createAccessClient(req, res) { { _id: { $in: linkedBoardIds } }, { $set: { accessGateCode: newAccessGate.code } } ); + } catch (err) { + await AccessClient.deleteOne({ _id: client._id }); + throw err; + } return res.status(201).json({ ...client.toJSON(), accessGate: newAccessGate.toJSON() }); } catch (err) { From 39b55dea011eda36cc13eb110023948521e865bb Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:29:28 -0300 Subject: [PATCH 097/113] Rename 'code' to 'slug' in access client response and update Swagger definition for clarity --- api/controllers/access.js | 30 +++++++++++++++--------------- api/swagger/swagger.yaml | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 08c5e8ab..fc813ae0 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -171,7 +171,7 @@ async function getAccessBoard(req, res) { return res.status(200).json({ client: { - code: client.slug, + slug: client.slug, name: client.contact.name, color: client.brandColor }, @@ -242,24 +242,24 @@ async function createAccessClient(req, res) { await client.save(); try{ - // Auto-discover all boards reachable from root via tile.loadBoard links - const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); + // Auto-discover all boards reachable from root via tile.loadBoard links + const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); - // Create the access gate + // Create the access gate const newAccessGate = new AccessGate({ - code: accessGateCode.toUpperCase(), - accessClient: client._id, - rootBoardId, - linkedBoardIds: linkedBoardIds - }); + code: accessGateCode.toUpperCase(), + accessClient: client._id, + rootBoardId, + linkedBoardIds: linkedBoardIds + }); - await newAccessGate.save(); + await newAccessGate.save(); - // Mark all discovered boards with the access gate code - await Board.updateMany( - { _id: { $in: linkedBoardIds } }, - { $set: { accessGateCode: newAccessGate.code } } - ); + // 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 }); throw err; diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 2124362f..d9d9abdd 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2403,9 +2403,9 @@ definitions: client: type: object properties: - code: + slug: type: string - description: Access code + description: Client slug identifier name: type: string description: Client name From ae939861a0149869fbc677ffb803c1cc2e1c8dc7 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:29:53 -0300 Subject: [PATCH 098/113] Refactor deleteBoard function to remove AccessGate updates and simplify logic --- api/controllers/board.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 3371330c..a5b1bca7 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -4,7 +4,6 @@ const moment = require('moment'); const { paginatedResponse } = require('../helpers/response'); const { getORQuery } = require('../helpers/query'); const Board = require('../models/Board'); -const AccessGate = require('../models/AccessGate'); const { getCbuilderBoardbyId } = require('../helpers/cbuilder'); const { processBase64Images, hasBase64Images } = require('../helpers/imageProcessor'); @@ -88,9 +87,9 @@ async function getPublicBoards(req, res) { return res.status(200).json(response); } -async function deleteBoard(req, res) { +function deleteBoard(req, res) { const id = req.swagger.params.id.value; - Board.findByIdAndRemove(id, async function (err, boards) { + Board.findByIdAndRemove(id, function (err, boards) { if (err) { return res.status(404).json({ message: 'Board not found. Board Id: ' + id, @@ -103,14 +102,6 @@ async function deleteBoard(req, res) { error: 'Board not found.' }); } - await AccessGate.updateMany( - { linkedBoardIds: id }, - { $pull: { linkedBoardIds: id } } - ); - await AccessGate.updateMany( - { rootBoardId: id }, - { $set: { rootBoardId: null } } - ); return res.status(200).json(boards); }); } From cfd99eae891ff01f6621b7aca01008afe95eab00 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:35:36 -0300 Subject: [PATCH 099/113] Refactor createAccessClient to declare newAccessGate variable before try block Co-authored-by: Copilot --- api/controllers/access.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index fc813ae0..a448aec5 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -241,12 +241,14 @@ async function createAccessClient(req, res) { await client.save(); - try{ + let newAccessGate; + + try { // Auto-discover all boards reachable from root via tile.loadBoard links const linkedBoardIds = await getAllLinkedBoardIds(rootBoardId); // Create the access gate - const newAccessGate = new AccessGate({ + newAccessGate = new AccessGate({ code: accessGateCode.toUpperCase(), accessClient: client._id, rootBoardId, From c5b3d4664d6585112163cd4237b35114bce8038e Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:43:47 -0300 Subject: [PATCH 100/113] Update getPublicBoards to filter accessGateCode for null and undefined values --- api/controllers/board.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index a5b1bca7..671498b4 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -80,7 +80,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true, accessGateCode: null } }, + { query: { ...query, isPublic: true, accessGateCode: { $in: [null, undefined] } } }, req.query ); From 3915ed5d7062be553b14ee9d6ffa3286174f1b6c Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 13:44:33 -0300 Subject: [PATCH 101/113] Change deleteBoard function to async for improved handling of asynchronous operations --- api/controllers/board.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index 671498b4..dcef6250 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -87,7 +87,7 @@ async function getPublicBoards(req, res) { return res.status(200).json(response); } -function deleteBoard(req, res) { +async function deleteBoard(req, res) { const id = req.swagger.params.id.value; Board.findByIdAndRemove(id, function (err, boards) { if (err) { From 7248917190fe745e0d0e43c7d849d615d8852e2f Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 14:16:20 -0300 Subject: [PATCH 102/113] Refactor AccessGate model and related functions to use accessClientId for consistency; update Swagger definitions and tests accordingly --- api/controllers/access.js | 26 +++++++++++++------------- api/models/AccessGate.js | 22 +++++++++++++++++++++- api/swagger/swagger.yaml | 17 ++++++++++++++--- test/controllers/access.js | 4 ++-- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index a448aec5..8ac637d9 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -64,10 +64,10 @@ async function getClients(req, res) { }).select('slug contact brandColor'); const accessGates = await AccessGate.find({ - accessClient: { $in: clients.map(c => c._id) } + accessClientId: { $in: clients.map(c => c._id) } }) - .populate('rootBoardId', 'name caption tiles') - .select('code rootBoardId accessClient'); + .populate('rootBoard', 'name caption tiles') + .select('code rootBoardId accessClientId'); const result = clients .map(client => ({ @@ -75,15 +75,15 @@ async function getClients(req, res) { clientName: client.contact.name, brandColor: client.brandColor, accessGates: accessGates - .filter(ag => ag.accessClient.toString() === client._id.toString()) + .filter(ag => ag.accessClientId.toString() === client._id.toString()) .map(ag => ({ code: ag.code, - rootBoard: ag.rootBoardId + rootBoard: ag.rootBoard ? { - id: ag.rootBoardId._id, - name: ag.rootBoardId.name, - caption: ag.rootBoardId.caption, - tilesCount: ag.rootBoardId.tiles?.length || 0 + id: ag.rootBoard._id, + name: ag.rootBoard.name, + caption: ag.rootBoard.caption, + tilesCount: ag.rootBoard.tiles?.length || 0 } : null })) @@ -250,7 +250,7 @@ async function createAccessClient(req, res) { // Create the access gate newAccessGate = new AccessGate({ code: accessGateCode.toUpperCase(), - accessClient: client._id, + accessClientId: client._id, rootBoardId, linkedBoardIds: linkedBoardIds }); @@ -296,10 +296,10 @@ async function listAccessClients(req, res) { // Fetch access gates and sum linkedBoardIds per client to avoid N+1 const clientIds = clients.map(c => c._id); - const accessGates = await AccessGate.find({ accessClient: { $in: clientIds } }); + const accessGates = await AccessGate.find({ accessClientId: { $in: clientIds } }); const agMap = accessGates.reduce((map, ag) => { - const key = ag.accessClient.toString(); + const key = ag.accessClientId.toString(); if (!map[key]) map[key] = 0; map[key] += ag.linkedBoardIds?.length || 0; return map; @@ -385,7 +385,7 @@ async function getAccessClientStats(req, res) { return res.status(404).json({ message: 'Client not found' }); } - const accessGates = await AccessGate.find({ accessClient: client._id }); + const accessGates = await AccessGate.find({ accessClientId: client._id }); // Collect unique board IDs across all access gates const allBoardIds = [ diff --git a/api/models/AccessGate.js b/api/models/AccessGate.js index 2e7c7216..3899a4fa 100644 --- a/api/models/AccessGate.js +++ b/api/models/AccessGate.js @@ -12,7 +12,7 @@ const ACCESS_GATE_SCHEMA_DEFINITION = { unique: true, index: true }, - accessClient: { + accessClientId: { type: Schema.Types.ObjectId, ref: 'AccessClient', required: true @@ -57,6 +57,26 @@ const accessGateSchema = new Schema( 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/swagger/swagger.yaml b/api/swagger/swagger.yaml index d9d9abdd..5db86f0d 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2463,17 +2463,28 @@ definitions: code: type: string description: Access gate code (uppercase) - accessClient: + accessClientId: type: string - description: AccessClient ID + description: AccessClient ID (foreign key) + accessClient: + type: object + description: Populated AccessClient document (present when populated via virtual) rootBoardId: type: string - description: Root board ID + 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 diff --git a/test/controllers/access.js b/test/controllers/access.js index 4ecc058b..ebd7573a 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -45,7 +45,7 @@ describe('Access API calls', function () { const clients = await AccessClient.find({ 'contact.name': /mocha test/i }); const clientIds = clients.map(c => c._id); - await AccessGate.deleteMany({ accessClient: { $in: clientIds } }); + await AccessGate.deleteMany({ accessClientId: { $in: clientIds } }); await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); }); @@ -55,7 +55,7 @@ describe('Access API calls', function () { 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({ accessClient: { $in: clientIds } }); + await AccessGate.deleteMany({ accessClientId: { $in: clientIds } }); await AccessClient.deleteMany({ 'contact.name': /mocha test/i }); }); From ca33ce739a9fdfb68f5e3ec239cb8d1416ac66fc Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 14:20:28 -0300 Subject: [PATCH 103/113] Rename getAccessBoard to getAccessBoards for consistency in API and update Swagger definition accordingly --- api/controllers/access.js | 4 ++-- api/swagger/swagger.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 8ac637d9..e25e0c07 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -6,7 +6,7 @@ const Board = require('../models/Board'); module.exports = { getClients: getClients, - getAccessBoard: getAccessBoard, + getAccessBoards: getAccessBoards, createAccessClient: createAccessClient, listAccessClients: listAccessClients, updateAccessClient: updateAccessClient, @@ -110,7 +110,7 @@ async function getClients(req, res) { * Returns client info (slug, name, color), all boards array, and rootBoardId. * Increments viewsCount and updates lastAccessAt on the AccessGate. */ -async function getAccessBoard(req, res) { +async function getAccessBoards(req, res) { const code = req.swagger.params.gateCode.value.toUpperCase(); const slug = req.swagger.params.clientSlug.value; diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 5db86f0d..889c4731 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -1281,7 +1281,7 @@ paths: /access/{clientSlug}/{gateCode}: x-swagger-router-controller: access get: - operationId: getAccessBoard + 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 From dbe4483e94c74d56c40baad614f12ed2086e2949 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 14:48:14 -0300 Subject: [PATCH 104/113] Improve analytics tracking in getAccessBoards to be fire-and-forget, ensuring non-blocking responses and consistent timestamps --- api/controllers/access.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index e25e0c07..d3372ea3 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -160,13 +160,19 @@ async function getAccessBoards(req, res) { }); } - // Track access analytics atomically to avoid lost updates under concurrency - await AccessGate.updateOne( + // 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 }, - $set: { lastAccessAt: new Date() } + $currentDate: { lastAccessAt: true } } + ).catch(err => + console.error('[access] viewsCount update failed', { + gateId: accessGate._id, + error: err.message + }) ); return res.status(200).json({ From c73aa17950e27fe8754c33b910d3fc55ae3277bf Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Fri, 24 Apr 2026 14:50:19 -0300 Subject: [PATCH 105/113] Add tests for GET /access/:clientSlug/:gateCode endpoint, including analytics tracking and error handling --- test/controllers/access.js | 126 +++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/test/controllers/access.js b/test/controllers/access.js index ebd7573a..a4bc61ec 100644 --- a/test/controllers/access.js +++ b/test/controllers/access.js @@ -509,4 +509,130 @@ describe('Access API calls', function () { .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'); + }); + }); }); From cea19c5e6de66f91fcb5b7816c169da220659841 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Wed, 29 Apr 2026 15:39:06 -0300 Subject: [PATCH 106/113] Add QA image job to CircleCI configuration for Docker builds --- .circleci/config.yml | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 742ed1e0..8bd977bb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -157,6 +157,56 @@ jobs: -e APPLE_KEY_ID \ cboard/cboard-bootstrap \ up -d --no-deps cboard-api" && exit' + 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: @@ -170,3 +220,10 @@ workflows: filters: branches: only: master + - qa-image: + context: google-auth + requires: + - build + filters: + branches: + only: feature/cboard-access From b36807c5c235dddb2f17818b4abd1df8513ebb94 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Tue, 19 May 2026 18:05:19 -0300 Subject: [PATCH 107/113] Revert "Add QA image job to CircleCI configuration for Docker builds" This reverts commit cea19c5e6de66f91fcb5b7816c169da220659841. --- .circleci/config.yml | 57 -------------------------------------------- 1 file changed, 57 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8bd977bb..742ed1e0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -157,56 +157,6 @@ jobs: -e APPLE_KEY_ID \ cboard/cboard-bootstrap \ up -d --no-deps cboard-api" && exit' - 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: @@ -220,10 +170,3 @@ workflows: filters: branches: only: master - - qa-image: - context: google-auth - requires: - - build - filters: - branches: - only: feature/cboard-access From 5d3ec19144f4d185cbbf2a563adc0eeb664ebc65 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 19 May 2026 18:58:38 -0300 Subject: [PATCH 108/113] Use accessGateCode: null instead of $in:[null,undefined] in public boards query --- api/controllers/board.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/board.js b/api/controllers/board.js index dcef6250..fc531597 100644 --- a/api/controllers/board.js +++ b/api/controllers/board.js @@ -80,7 +80,7 @@ async function getPublicBoards(req, res) { search && search.length ? getORQuery(searchFields, search, true) : {}; const response = await paginatedResponse( Board, - { query: { ...query, isPublic: true, accessGateCode: { $in: [null, undefined] } } }, + { query: { ...query, isPublic: true, accessGateCode: null } }, req.query ); From 1d5e59cd2df0e7835981b3fa209597c18be27a0d Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 19 May 2026 18:59:52 -0300 Subject: [PATCH 109/113] Delete orphaned AccessGate on createAccessClient rollback --- api/controllers/access.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/controllers/access.js b/api/controllers/access.js index d3372ea3..8f3e869c 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -270,6 +270,9 @@ async function createAccessClient(req, res) { ); } catch (err) { await AccessClient.deleteOne({ _id: client._id }); + if (newAccessGate?._id) { + await AccessGate.deleteOne({ _id: newAccessGate._id }); + } throw err; } From 9880069d976961e86d7173125847f6993676d427 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 19 May 2026 19:00:59 -0300 Subject: [PATCH 110/113] Scope accessGateCode clear to current gate only in updateAccessGate --- api/controllers/access.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/controllers/access.js b/api/controllers/access.js index 8f3e869c..a30ff285 100644 --- a/api/controllers/access.js +++ b/api/controllers/access.js @@ -474,7 +474,7 @@ async function updateAccessGate(req, res) { if (removedIds.length) { await Board.updateMany( - { _id: { $in: removedIds } }, + { _id: { $in: removedIds }, accessGateCode: code }, { $set: { accessGateCode: null } } ); } From a39cba9145f20fa19afbfbc27a6fa33fde2257ee Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 19 May 2026 19:02:17 -0300 Subject: [PATCH 111/113] Relax createdBy schema to allow populated user object in AccessClientResponse --- api/swagger/swagger.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/swagger/swagger.yaml b/api/swagger/swagger.yaml index 889c4731..583ab1dd 100644 --- a/api/swagger/swagger.yaml +++ b/api/swagger/swagger.yaml @@ -2278,8 +2278,7 @@ definitions: format: date-time description: Subscription end date createdBy: - type: string - description: ID of user who created the client + description: User who created the client (string ID or populated user object with name, email, role) createdAt: type: string format: date-time From 00d4ef03dae4806b84c74c3d381ad4fb13bf0135 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 19 May 2026 19:03:19 -0300 Subject: [PATCH 112/113] Update JSDoc for PrepareUserResponse and createMochaBoard to include email param --- test/helper.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/helper.js b/test/helper.js index 8e607bf7..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 */ /** @@ -474,7 +475,8 @@ 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} */ From 85f0e4f6cb9ce2723072878c171f8d2db09f97b8 Mon Sep 17 00:00:00 2001 From: Rodri Sanchez Date: Tue, 7 Jul 2026 17:14:17 -0300 Subject: [PATCH 113/113] Reapply "Add QA image job to CircleCI configuration for Docker builds" This reverts commit b36807c5c235dddb2f17818b4abd1df8513ebb94. --- .circleci/config.yml | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) 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