Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy-to-prod.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# =============================================================================
# Deploy to Production
# =============================================================================
# Manually triggered workflow to deploy a tested version to production.
# Manually triggered workflow to deploy a version tag to production.
# Requires semantic version tag (v1.2.3).
# Validates that version was deployed to test first.
# Checks that the git tag and its image tag exist; it does not check that the version ran on test.
# =============================================================================

name: Deploy to Prod
Expand Down
7 changes: 0 additions & 7 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
## Updates

- 2026-09-23: Add DB tests that drive POST/PUT with the bodies eagle-admin actually sends.
- 2026-09-24: `documentAggregator.js:97` `signedIn` repeats the isAuthenticated test at `search.js:227`; pass the flag in.
- 2026-09-24: GET `/public/document/{id}` and `/fetch` serve an UPDATE image of a scheduled Update before it goes live (public at save); check the Update is live, or accept.

## Sorting
Expand All @@ -13,14 +12,8 @@
## Legislation registry: deferred review findings

- 2026-10-01 api/controllers/project.js:634: a PUT with no `legislationYear` returns 404 when the stored `currentLegislationYear` is missing or not an exact key (`'legislation_2018 '`, old untrimmed publish). Count such rows on test; if any exist, read the stored year suffix with `Number()`.
- 2026-10-01 api/controllers/project.js:619: no test for a PUT that moves a 1996, 2002 or 2018 project to 2025 (allowed, and one-way after that).
- 2026-10-01 api/dao/projectDAO.js:53: `publishProject` sets `currentLegislationYear` with no lock and no content check; its only caller is its own test. Delete it, or send it through the controller checks.
- 2026-10-01 api/controllers/project.js:741: the 409 for a project that moved under a locked Act before the write names every locked Act joined with "or" (PUT and publish). Read the stored key back and name only that Act; test/controllers/project-locked-act.test.js:161 asserts the joined wording.
- 2026-10-01 api/controllers/project.js:775: a non-numeric year on a locked project gets 404 from publish but 409 from PUT.
- 2026-10-01 api/swagger/swagger.yaml:1348: the PUT "404" text should say a locked project gets 409 first.
- 2026-10-01 api/swagger/swagger.yaml:1438: the publish "404" response has no `$ref: "#/definitions/Error"`.
- 2026-10-01 api/helpers/demiPush.js:100: `enrichProject` adds proponent and regulation keys to the default-filled 2025 block of 1996, 2002 and 2018 projects (push payload only).
- 2026-10-01 test/controllers/analytics-callsites.test.js:80, test/controllers/demi-push-callsites.test.js:100: only fixtures changed here; check these tests still fail when the code they cover breaks.
- 2026-10-01 api/helpers/models/project.js:103: every loaded project gains a default `legislation_2025` object, shown in PUT, publish and unpublish responses and the push body, stored on the next `save()`; the `handleProjectTerms` OR at api/helpers/aggregators.js:308 can match it.
- 2026-10-01 api/helpers/models/project.js:103: each Act adds 5 indexes to the shared `epic` collection (limit 64). Check the live index count before a production deploy.
- 2026-10-01 api/aggregators/projectAggregator.js:16: `projectLegislation=all` runs extra lookups per row.
Expand Down
5 changes: 3 additions & 2 deletions api/aggregators/documentAggregator.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ const { IMAGE_SOURCE } = require('../helpers/updateRules');
* @param {boolean} caseSensitive Case sensitive search?
* @param {array} orModifier Search criteria for an 'or' search
* @param {array} andModifier Search criteria for an 'and' search
* @param {boolean} categorized true: categorized only, false: uncategorized only, unset: all
* @param {array} roles User's roles
* @param {boolean} signedIn Caller is signed in (search.js isAuthenticated)
*
* @returns {array} Aggregation for a document match
*/
exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive, orModifier, andModifier, categorized, roles) => {
exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive, orModifier, andModifier, categorized, roles, signedIn = false) => {
const aggregation = [];
let projectModifier;
let keywordModifier;
Expand Down Expand Up @@ -94,7 +96,6 @@ exports.createMatchAggr = async (schemaName, projectId, keywords, caseSensitive,

// Update form images belong to their Update, not the document lists, unless a signed-in caller asks
// for a source. A scheduled Update's images are public before it is live, so the public never gets them here.
const signedIn = Array.isArray(roles) && roles.some(role => role !== 'public');
const asksForSource = [andModifier, orModifier].some(m => Object.keys(qs.parse(m)).includes('documentSource'));
const sourceModifier = asksForSource && signedIn
? undefined
Expand Down
56 changes: 43 additions & 13 deletions api/controllers/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ var Actions = require('../helpers/actions');
var Utils = require('../helpers/utils');
const demiPush = require('../helpers/demiPush');
const { DateTime } = require('luxon');
const { COMMENT_STAFF_FIELDS } = require('../helpers/constants');

const ALLOWED_FIELDS = [
'author',
Expand All @@ -29,6 +30,24 @@ const ALLOWED_FIELDS = [
'write',
'delete'
];
// Staff review fields stay off the public routes.
const PUBLIC_FIELDS = ALLOWED_FIELDS.filter(f => !COMMENT_STAFF_FIELDS.includes(f));

// Hiding an anonymous author needs isAnonymous: read it with author, drop it when the caller did not ask.
const publicCommentQuery = fields => {
const readsAnonymity = fields.includes('author') && !fields.includes('isAnonymous');
return {
fields: readsAnonymity ? [...fields, 'isAnonymous'] : fields,
redact: item => {
if (item.isAnonymous !== false) {
delete item.author;
}
if (readsAnonymity) {
delete item.isAnonymous;
}
}
};
};

var setPermissionsFromEaoStatus = function (status, comment) {
defaultLog.debug('setPermissionsFromEaoStatus: %s', status);
Expand Down Expand Up @@ -78,7 +97,7 @@ exports.publicHead = async function (args, res) {
query = Utils.buildQuery('period', args.swagger.params.period.value, query);
}

const fields = Utils.sanitizeFields(args.swagger.params.fields.value, ALLOWED_FIELDS);
const fields = Utils.sanitizeFields(args.swagger.params.fields.value, PUBLIC_FIELDS);

// Set query type
Object.assign(query, { '_schemaName': 'Comment' });
Expand All @@ -103,10 +122,13 @@ exports.publicHead = async function (args, res) {
exports.publicGet = async function (args, res) {
var query = {}, sort = {};
var skip = null, limit = null;
const commentId = args.swagger.params.commentId && args.swagger.params.commentId.value;
// The by-id route declares no `count` parameter.
const wantsCount = Boolean(args.swagger.params.count && args.swagger.params.count.value);

// Build match query if on commentId route.
if (args.swagger.params.commentId && args.swagger.params.commentId.value) {
query = Utils.buildQuery('_id', args.swagger.params.commentId.value, query);
if (commentId) {
query = Utils.buildQuery('_id', commentId, query);
} else {
if (args.swagger.params.period && args.swagger.params.period.value) {
query = Utils.buildQuery('period', args.swagger.params.period.value, query);
Expand All @@ -125,34 +147,42 @@ exports.publicGet = async function (args, res) {
limit = processedParameters.limit;
}

const fields = Utils.sanitizeFields(args.swagger.params.fields.value, ALLOWED_FIELDS);
const commentQuery = publicCommentQuery(Utils.sanitizeFields(args.swagger.params.fields.value, PUBLIC_FIELDS));
// Set query type
Object.assign(query, { '_schemaName': 'Comment' });

var data = await Utils.runDataQuery('Comment',
['public'],
query,
fields, // Fields
commentQuery.fields, // Fields
null,
sort, // sort
skip, // skip
limit, // limit
true); // count

const results = data[0] ? data[0].results : [];
results.forEach(commentQuery.redact);

// An array, empty when not found: eagle-public tags v2.7.36 and older and the DEMI parity check both read it so.
if (commentId) {
if (results.length === 0) {
defaultLog.info('publicGet: comment %s not found', commentId);
} else {
Utils.recordAction('Get', 'Comment', 'public', commentId);
}
return Actions.sendResponse(res, 200, results);
}

if (data[0] == null) {
if (args.swagger.params.count.value) {
if (wantsCount) {
res.setHeader('x-total-count', 0);
}
return Actions.sendResponse(res, 200, data);
}

data[0].results.forEach(function (item) {
if (item.isAnonymous === true) {
delete item.author;
}
});
if (args.swagger.params.count.value) {
Utils.recordAction('Get', 'Comment', 'public', args.swagger.params.commentId && args.swagger.params.commentId.value ? args.swagger.params.commentId.value : null);
if (wantsCount) {
Utils.recordAction('Get', 'Comment', 'public', null);
res.setHeader('x-total-count', data && data.length > 0 ? data[0].total_items : 0);
return Actions.sendResponse(res, 200, data.length !== 0 ? data[0].results : []);
} else {
Expand Down
2 changes: 1 addition & 1 deletion api/controllers/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ exports.protectedPublish = async function (args, res) {

if (ProjObject && ProjObject.legislationYear) {
const year = Number(ProjObject.legislationYear);
if (refusesLegislationChange(o, year)) {
if (refusesLegislationChange(o, ProjObject.legislationYear)) {
const current = [o.currentLegislationYear];
defaultLog.warn('protectedPublish refused: project %s is under %s, request named %s', objId, actLabels(current), ProjObject.legislationYear);
return Actions.sendResponse(res, 409, lockedActRefusal(current));
Expand Down
14 changes: 9 additions & 5 deletions api/controllers/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const Actions = require('../helpers/actions');
const Utils = require('../helpers/utils');
const analytics = require('../helpers/analytics');
const constants = require('../helpers/constants').schemaTypes;
const { COMMENT_STAFF_FIELDS } = require('../helpers/constants');

const documentAggregator = require('../aggregators/documentAggregator');
const projectAggregator = require('../aggregators/projectAggregator');
Expand Down Expand Up @@ -33,7 +34,7 @@ const PROJECT_JOINED_FIELDS = ['proponent', 'currentPhaseName', 'eacDecision', '
// Project fields still on the root before the page is cut; unwindProjectData copies them into default after it.
const PROJECT_ROOT_SORT_FIELDS = ['_id', ...aggregateHelper.PROJECT_ROOT_FIELDS];

const searchCollection = async function (roles, keywords, schemaName, pageNum, pageSize, project, projectLegislation, sortField = undefined, sortDirection = undefined, caseSensitive, populate = false, and, or, sortingValue, categorized, fuzzy) {
const searchCollection = async function (roles, keywords, schemaName, pageNum, pageSize, project, projectLegislation, sortField = undefined, sortDirection = undefined, caseSensitive, populate = false, and, or, sortingValue, categorized, fuzzy, signedIn) {
defaultLog.debug('collation:', aggregateHelper.AGGREGATE_COLLATION);
defaultLog.debug('populate:', populate);

Expand All @@ -49,7 +50,7 @@ const searchCollection = async function (roles, keywords, schemaName, pageNum, p
let parentGateAggregation = [];
switch (schemaName) {
case constants.DOCUMENT: {
matchAggregation = await documentAggregator.createMatchAggr(schemaName, project, decodedKeywords, caseSensitive, or, and, categorized, roles, fuzzy);
matchAggregation = await documentAggregator.createMatchAggr(schemaName, project, decodedKeywords, caseSensitive, or, and, categorized, roles, signedIn);
const unreadableParentIds = await parentRead.unreadableParentIds(roles);
schemaAggregation = await documentAggregator.createDocumentAggr(populate, roles, sortingValue, sortField, sortDirection, pageNum, pageSize, unreadableParentIds);
break;
Expand Down Expand Up @@ -342,7 +343,7 @@ const executeQuery = async function (args, res) {
}

if (dataset !== constants.ITEM) {
const collectionData = await searchCollection(roles, keywords, dataset, pageNum, pageSize, project, projectLegislation, sortField, sortDirection, caseSensitive, populate, and, or, sortingValue, categorized, fuzzy);
const collectionData = await searchCollection(roles, keywords, dataset, pageNum, pageSize, project, projectLegislation, sortField, sortDirection, caseSensitive, populate, and, or, sortingValue, categorized, fuzzy, isAuthenticated);

// Only a keyword search counts: this endpoint also serves plain browse and filter requests, and
// those would drown the searches someone actually typed.
Expand All @@ -358,7 +359,7 @@ const executeQuery = async function (args, res) {
if (dataset === constants.COMMENT) {
// Filter
collectionData[0].searchResults.forEach(function (item) {
if (item.isAnonymous === true) {
if (item.isAnonymous !== false) {
delete item.author;
}
});
Expand All @@ -383,9 +384,12 @@ const executeQuery = async function (args, res) {
if (schemaNameVal === constants.COMMENT) {
// Filter
data.forEach(function (item) {
if (item.isAnonymous === true) {
if (item.isAnonymous !== false) {
delete item.author;
}
if (!isAuthenticated) {
COMMENT_STAFF_FIELDS.forEach(field => delete item[field]);
}
});
}

Expand Down
18 changes: 0 additions & 18 deletions api/dao/projectDAO.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const defaultLog = require('winston').loggers.get('default');
const mongoose = require('mongoose');
const Actions = require('../helpers/actions');
const Utils = require('../helpers/utils');
const { legislationKey } = require('../helpers/constants');

exports.projectHateoas = function(project, roles) {
project.links =
Expand Down Expand Up @@ -50,23 +49,6 @@ exports.deleteProject = async function(user, project) {
});
};

exports.publishProject = async function(user, project) {
const key = project && project.legislationYear ? legislationKey(project.legislationYear) : null;
if (key) {
project.currentLegislationYear = key;
}

return Actions.publish(project, true)
.then(function (published) {
Utils.recordAction('Publish', 'Project', user, project._id);

return published;
})
.catch(function (err) {
throw Error('Failed to publish project', err);
});
};

exports.unPublishProject = async function(user, project) {
return Actions.unPublish(project, true)
.then(function (unpublished) {
Expand Down
3 changes: 3 additions & 0 deletions api/helpers/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ exports.MAX_FEATURE_DOCS = 5;
exports.PUBLIC_ROLES = ['public'];
exports.SECURE_ROLES = ['sysadmin', 'staff'];

// Comment fields only signed-in staff may read.
exports.COMMENT_STAFF_FIELDS = Object.freeze(['eaoNotes', 'eaoStatus', 'proponentNotes', 'rejectedNotes', 'rejectedReason']);

// Legislation registry, keyed by year. A project keeps one sub-document per year under `legislation_<year>`.
// Adding an Act: add its entry here and its year to the project POST and publish prose in swagger.yaml (a test checks).
// `label`: the `legislation` string written on the project; eagle-public matches on it.
Expand Down
39 changes: 32 additions & 7 deletions api/swagger/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,25 @@ definitions:
- 'read'
- 'write'
- 'delete'
PublicCommentFields:
type: string
description: "Optional fields to return on the public routes: no staff review fields."
enum: &publicCommentFields
- 'author'
- 'comment'
- 'commentId'
- 'dateAdded'
- 'datePosted'
- 'dateUpdated'
- 'isAnonymous'
- 'location'
- 'period'
- 'proponentStatus'
- 'publishedNotes'
- 'valuedComponents'
- 'read'
- 'write'
- 'delete'
CommentSortByFields:
type: string
description: "Optional fields to sort by (prefixed with + or - for order)"
Expand Down Expand Up @@ -1306,7 +1325,9 @@ paths:
schema:
$ref: "#/definitions/Error"
"404":
description: "Project not found, or legislationYear is not a known legislation year"
description: "Project not found, or legislationYear is not a known legislation year. A project under a locked Act gets 409 first when legislationYear names any other year"
schema:
$ref: "#/definitions/Error"
"409":
description: "Project's current Act is locked (today the Building Canada Act, 2025) and legislationYear names another year"
schema:
Expand Down Expand Up @@ -1396,7 +1417,9 @@ paths:
schema:
$ref: "#/definitions/Error"
"404":
description: "Project not found, or legislationYear is not a known legislation year"
description: "Project not found, or legislationYear is not a known legislation year. A project under a locked Act gets 409 first when legislationYear names any other year"
schema:
$ref: "#/definitions/Error"
"409":
description: "Project's current Act is locked (today the Building Canada Act, 2025) and legislationYear names another year, or legislationYear is not the current year and the project has no content under it"
schema:
Expand Down Expand Up @@ -5310,7 +5333,7 @@ paths:
collectionFormat: pipes
items:
type: string
enum: *commentFields
enum: *publicCommentFields
responses:
"200":
description: "Success"
Expand Down Expand Up @@ -5351,7 +5374,7 @@ paths:
collectionFormat: pipes
items:
type: string
enum: *commentFields
enum: *publicCommentFields
- in: query
name: pageNum
type: number
Expand Down Expand Up @@ -5422,12 +5445,14 @@ paths:
collectionFormat: pipes
items:
type: string
enum: *commentFields
enum: *publicCommentFields
responses:
"200":
description: "Success"
description: "An array holding the one comment, empty when no public comment has this id"
schema:
$ref: "#/definitions/CommentObject"
type: array
items:
$ref: "#/definitions/CommentObject"
"403":
description: "Access Denied"
schema:
Expand Down
12 changes: 12 additions & 0 deletions test/aggregators/aggregators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ describe('Document Aggregator', () => {
expect(result[0].$match).to.have.property('milestone');
expect(result[0].$match).to.have.property('documentAuthorType');
});

it('keeps Update images when a signed-in caller filters on documentSource, even one with no roles', async () => {
const result = await documentAggregator.createMatchAggr('Document', null, null, false, null, 'documentSource=UPDATE', null, [], true);

expect(result[0].$match).to.not.have.property('documentSource');
});

it('hides Update images from a caller who is not signed in, even when it filters on documentSource', async () => {
const result = await documentAggregator.createMatchAggr('Document', null, null, false, null, 'documentSource=UPDATE', null, ['public'], false);

expect(result[0].$match.documentSource).to.deep.equal({ $ne: 'UPDATE' });
});
});

describe('createDocumentAggr', () => {
Expand Down
Loading
Loading