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
15 changes: 12 additions & 3 deletions api/src/database/migrations/supplier-email-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ const options = {
partialFilterExpression: { email: { $type: 'string', $gt: '' } },
};

async function migrateContactEmailIndex(db, name, indexOptions) {
async function migrateContactEmailIndex(db, name, indexOptions, keys = { email: 1 }) {
if (!(await db.listCollections({ name }, { nameOnly: true }).hasNext())) return;
const collection = db.collection(name);
const indexes = await collection.listIndexes().toArray();
// Build first; never delete contacts or remove existing constraints to make
// a new uniqueness rule fit historical data. A duplicate is a deferred
// migration, not a reason to take the entire till offline.
try {
await collection.createIndex({ email: 1 }, indexOptions);
await collection.createIndex(keys, indexOptions);
} catch (error) {
if (error?.code !== 11000) throw error;
console.warn(
Expand All @@ -36,7 +36,16 @@ async function migrateContactEmailIndex(db, name, indexOptions) {
}
}

const migrateSupplierEmailIndex = (db) => migrateContactEmailIndex(db, 'suppliers', options);
const migrateSupplierEmailIndex = (db) =>
migrateContactEmailIndex(
db,
'suppliers',
{
...options,
name: 'supplier_branch_nonempty_email',
},
{ license: 1, branch_id: 1, email: 1 }
);
const migrateCustomerEmailIndex = (db) =>
migrateContactEmailIndex(db, 'customers', {
...options,
Expand Down
13 changes: 12 additions & 1 deletion api/src/repositories/supplier.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ class SupplierRepository extends BaseModel {
/**
* Find supplier by email
*/
async findByEmail(email) {
async findByEmail(email, branchId = BaseModel.currentBranch) {
const collection = await this.getCollection(this.collectionName);
return await collection.findOne({
email,
license: BaseModel.license,
branch_id: branchId,
is_deleted: { $ne: true },
});
}
Expand Down Expand Up @@ -228,6 +229,9 @@ class SupplierRepository extends BaseModel {
throw new Error('Supplier not found');
}

const backup = await BaseModel.deletedDocumentBackup(this.collectionName, supplier);
if (!backup?.status) throw new Error(backup?.message || 'Supplier backup failed');

// Backup to recycle bin collection
const recycleCollection = await this.getCollection(this.collectionName + '_recycle_bin');
await recycleCollection.insertOne({
Expand Down Expand Up @@ -264,6 +268,13 @@ class SupplierRepository extends BaseModel {
return { deletedCount: 0 };
}

// The common recycle record is also the deletion event consumed by sync.
// Keep the legacy backup below for existing restore screens.
for (const supplier of suppliers) {
const backup = await BaseModel.deletedDocumentBackup(this.collectionName, supplier);
if (!backup?.status) throw new Error(backup?.message || 'Supplier backup failed');
}

// Backup to recycle bin collection
const recycleCollection = await this.getCollection(this.collectionName + '_recycle_bin');
const backupDocs = suppliers.map((supplier) => ({
Expand Down
25 changes: 24 additions & 1 deletion api/src/services/extension-namespace.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,29 @@ async function executeNamespace(db, scope, descriptor, actor, input, dependencie
)
)
fail('extension_result_invalid', 422);
const sequence = (row.effectSequence || 0) + 1;
// Older sync agents copied item replay fences between installations while
// namespace counters stayed local. Raise the counter once, before claiming
// a NEW command. Never renumber a pending command: some of its effects may
// already have committed. Item receipts and stock remain untouched.
let sequenceFloor = row.effectSequence || 0;
if (row.localStockFenceVersion !== 1) {
const field = `extension_stock_streams.${key._id}.sequence`;
const cursor = db.collection('items').find(
{
license: key.license,
[field]: { $exists: true },
$or: [{ branch_id: key.branch_id }, { 'branch_access.branch_id': key.branch_id }],
},
{ projection: { [field]: 1 } }
);
for await (const item of cursor) {
const receiptSequence = item.extension_stock_streams?.[key._id]?.sequence;
if (!Number.isSafeInteger(receiptSequence) || receiptSequence < 0)
fail('extension_stock_receipt_invalid');
sequenceFloor = Math.max(sequenceFloor, receiptSequence);
}
}
const sequence = sequenceFloor + 1;
if (!Number.isSafeInteger(sequence)) fail('extension_sequence_exhausted');
const locked = await namespaces.updateOne(
{
Expand All @@ -200,6 +222,7 @@ async function executeNamespace(db, scope, descriptor, actor, input, dependencie
{
$set: {
effectSequence: sequence,
localStockFenceVersion: 1,
pending: {
sequence,
operationId,
Expand Down
15 changes: 12 additions & 3 deletions api/src/services/supplier.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,10 @@ class SupplierService {

// Check for duplicate email
if (supplierData.email) {
const existingSupplier = await this.repository.findByEmail(supplierData.email);
const existingSupplier = await this.repository.findByEmail(
supplierData.email,
supplierData.branch_id
);
if (existingSupplier) {
return {
status: false,
Expand Down Expand Up @@ -297,7 +300,10 @@ class SupplierService {

// Check for duplicate email if email is being updated
if (updateData.email && updateData.email !== existingSupplier.email) {
const duplicateSupplier = await this.repository.findByEmail(updateData.email);
const duplicateSupplier = await this.repository.findByEmail(
updateData.email,
existingSupplier.branch_id
);
if (duplicateSupplier) {
return {
status: false,
Expand Down Expand Up @@ -650,7 +656,10 @@ class SupplierService {
address: existingSupplier.address || '',
});
} else {
if (supplier.email && (await this.repository.findByEmail(supplier.email))) {
if (
supplier.email &&
(await this.repository.findByEmail(supplier.email, supplier.branch_id))
) {
validationErrors.push({
name: supplier.name,
status: 'Email is already used by another supplier',
Expand Down
52 changes: 52 additions & 0 deletions api/tests/extension-namespace.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,58 @@ function fixture() {
}
const noEffect = { executeEffect: async () => ({ applied: true }) };

test('upgrade advances above imported fences without deleting items or replaying stock', async () => {
const f = fixture();
const token = require('node:crypto').createHash('sha256')
.update(`${f.scope.license}:${f.scope.branchId}:${f.descriptor.id}`).digest('hex');
const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId,
track_inventory: true, item_status: 'regular', available_quantity: 5,
extension_stock_streams: { [token]: { sequence: 330, digest: 'old-receipt', applied: true } } };
await db.collection('items').insertMany([item,
{ ...item, _id: new ObjectId(), license: new ObjectId(), extension_stock_streams: { [token]: { sequence: 999 } } },
{ ...item, _id: new ObjectId(), branch_id: new ObjectId(), extension_stock_streams: { [token]: { sequence: 999 } } }]);
const { applyStockEffect } = require('../src/services/extension-stock-effects');
const deps = { executeEffect: async context => applyStockEffect(db, f.scope, {
itemId: String(item._id), operationId: context.operationId, deltaMilli: -1000,
stream: { id: f.descriptor.id, sequence: context.sequence } }) };
const result = await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, deps);
assert.deepEqual(await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, deps), result);
const saved = await db.collection('items').findOne({ _id: item._id });
assert.equal(saved.available_quantity, 4);
assert.equal(saved.extension_stock_streams[token].sequence, 331);
const row = await db.collection('extension_namespaces').findOne({ _id: token });
assert.equal(row.effectSequence, 331);
assert.equal(row.localStockFenceVersion, 1);
assert.equal(await db.collection('items').countDocuments({ license: f.scope.license }), 2);
});

test('upgrade does not renumber a pending effect or discard its receipts', async () => {
const f = fixture();
await assert.rejects(executeNamespace(db, f.scope, f.descriptor, f.actor, f.input,
{ executeEffect: async () => { throw Error('interrupted'); } }), /interrupted/);
const row = await db.collection('extension_namespaces').findOne({ license: f.scope.license });
await db.collection('extension_namespaces').updateOne({ _id: row._id }, { $unset: { localStockFenceVersion: '' } });
const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId,
available_quantity: 4, extension_stock_streams: { [row._id]: { sequence: 330 } } };
await db.collection('items').insertOne(item);
await recoverNamespace(db, f.scope, f.descriptor, f.actor, {
executeEffect: async context => { assert.equal(context.sequence, 1); return { applied: true }; } });
assert.deepEqual(await db.collection('items').findOne({ _id: item._id }), item);
assert.equal((await db.collection('extension_namespaces').findOne({ _id: row._id })).effectSequence, 1);
});

test('invalid imported fence refuses a new command without changing stock', async () => {
const f = fixture();
const token = require('node:crypto').createHash('sha256')
.update(`${f.scope.license}:${f.scope.branchId}:${f.descriptor.id}`).digest('hex');
const item = { _id: new ObjectId(), license: f.scope.license, branch_id: f.scope.branchId,
available_quantity: 5, extension_stock_streams: { [token]: { sequence: 'bad' } } };
await db.collection('items').insertOne(item);
await assert.rejects(executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, noEffect), /extension_stock_receipt_invalid/);
assert.deepEqual(await db.collection('items').findOne({ _id: item._id }), item);
assert.equal((await db.collection('extension_namespaces').findOne({ _id: token })).pending, undefined);
});

test('repeated removal and restore retain state, effect receipts and other shops', async () => {
const f = fixture();
await executeNamespace(db, f.scope, f.descriptor, f.actor, f.input, noEffect);
Expand Down
2 changes: 1 addition & 1 deletion api/tests/extensions-routes.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ test('signed view and catalogue retain staff, installation and branch boundaries
headers: { Authorization: 'Bearer reader' },
});
assert.deepEqual((await list.json()).extensions, [
{ id: descriptor.id, displayName: descriptor.displayName, version: descriptor.version, enabled: true, installed: true },
{ id: descriptor.id, displayName: descriptor.displayName, version: descriptor.version, enabled: true, installed: true, salesWorkspace: null },
]);
assert.equal((await request('/view', { token: 'key' })).status, 403);
const view = await (await request('/view', { token: 'reader' })).json();
Expand Down
30 changes: 29 additions & 1 deletion api/tests/unit/database/optional-email-indexes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,32 @@ const makeDb = (index = { name: 'email_1', key: { email: 1 }, unique: true }) =>
};

describe('migrateOptionalEmailIndexes', () => {
test('replaces the old business-wide supplier constraint only after the branch index exists', async () => {
const { db, collections } = makeDb({
name: 'supplier_nonempty_email',
key: { email: 1 },
unique: true,
});
await migrateOptionalEmailIndexes(db);
const suppliers = collections.get('suppliers');
expect(suppliers.createIndex).toHaveBeenCalledWith(
{ license: 1, branch_id: 1, email: 1 },
expect.objectContaining({ name: 'supplier_branch_nonempty_email', unique: true })
);
expect(suppliers.createIndex.mock.invocationCallOrder[0]).toBeLessThan(
suppliers.dropIndex.mock.invocationCallOrder[0]
);
});

test('retains the old supplier constraint and contacts if branch uniqueness cannot be built', async () => {
const { db, collections } = makeDb();
collections.get('suppliers').createIndex.mockRejectedValue({ code: 11000 });
const warning = jest.spyOn(console, 'warn').mockImplementation(() => {});
await migrateOptionalEmailIndexes(db);
expect(collections.get('suppliers').dropIndex).not.toHaveBeenCalled();
expect(collections.get('suppliers').updateMany).not.toHaveBeenCalled();
warning.mockRestore();
});
test('unsets blank emails and replaces non-sparse unique indexes', async () => {
const { db, collections } = makeDb();
await migrateOptionalEmailIndexes(db);
Expand All @@ -36,7 +62,9 @@ describe('migrateOptionalEmailIndexes', () => {
) {
expect(collection.updateMany).not.toHaveBeenCalled();
expect(collection.createIndex).toHaveBeenCalledWith(
{ email: 1 },
collection === collections.get('suppliers')
? { license: 1, branch_id: 1, email: 1 }
: { email: 1 },
expect.objectContaining({
unique: true,
partialFilterExpression: { email: { $type: 'string', $gt: '' } },
Expand Down
20 changes: 20 additions & 0 deletions api/tests/unit/repositories/supplier.repository.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jest.mock('../../../src/models/base.model', () => {
}
MockBaseModel.prototype.getCollection = jest.fn();
MockBaseModel.simplifyFields = jest.fn((doc) => doc);
MockBaseModel.deletedDocumentBackup = jest.fn().mockResolvedValue({ status: true });
MockBaseModel.license = null;
MockBaseModel.currentBranch = null;
MockBaseModel.loggedUser = null;
Expand Down Expand Up @@ -461,6 +462,25 @@ describe('SupplierRepository', () => {
// ── bulkSoftDelete ───────────────────────────────────────────────────────────

describe('bulkSoftDelete', () => {
test('publishes a sync deletion record before removing suppliers', async () => {
const doc = { _id: FAKE_ID, branch_id: FAKE_BRANCH, license: FAKE_LICENSE };
col.find.mockReturnValue(mkChain([doc]));
await repo.bulkSoftDelete([FAKE_ID]);
expect(BaseModel.deletedDocumentBackup).toHaveBeenCalledWith('suppliers', doc);
expect(BaseModel.deletedDocumentBackup.mock.invocationCallOrder[0]).toBeLessThan(
col.deleteMany.mock.invocationCallOrder[0]
);
});

test('does not delete when the sync backup cannot be saved', async () => {
col.find.mockReturnValue(mkChain([{ _id: FAKE_ID }]));
BaseModel.deletedDocumentBackup.mockResolvedValueOnce({
status: false,
message: 'Backup unavailable',
});
await expect(repo.bulkSoftDelete([FAKE_ID])).rejects.toThrow('Backup unavailable');
expect(col.deleteMany).not.toHaveBeenCalled();
});
test('backs up and deletes multiple suppliers', async () => {
const docs = [{ _id: FAKE_ID }, { _id: FAKE_ID_2 }];
col.find.mockReturnValue(mkChain(docs));
Expand Down
11 changes: 11 additions & 0 deletions frontend/layouts/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ <h1 class="title_h2"><lang class="lang_posnic">Posnic</lang></h1>
</div>
<div class="infobar">
<ul class="list-inline mb-0">
<li class="list-inline-item" id="header_branch_switcher" hidden>
<div class="dropdown">
<button type="button" class="btn btn-link dropdown-toggle" id="header_branch_button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Switch branch" data-t-aria-label="lang_switch_branch">
<i class="feather icon-git-branch" aria-hidden="true"></i>
<span id="header_current_branch" style="display:inline-block;max-width:180px;overflow:hidden;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap"></span>
</button>
<div class="dropdown-menu dropdown-menu-right" id="header_branch_list"
aria-labelledby="header_branch_button" style="max-height:320px;overflow-y:auto;max-width:360px"></div>
</div>
</li>
<li class="list-inline-item mobile-hide">
<div class="languagebar ">
<div class="dropdown">
Expand Down
27 changes: 25 additions & 2 deletions frontend/static/script/js/core/PosnicPro.js
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ PosnicPro = {
});
},
setBranchDropdownOption: function (data) {
PosnicPro.renderBranchSwitcher(data);
var allbranch = [];
var branchOption = [];
var dashboardBranchOption = [];
Expand All @@ -591,11 +592,11 @@ PosnicPro = {
AllbranchOption += '<option name="selectall" value="' + allbranch + '">selectall</option><option name="removeall" value="' + allbranch + '">removeall</option>';
$('.dropdown-cursor-pointer').attr("disabled", false).css({ cursor: 'pointer' });
$('#dropdownMenuLink').addClass('dropdown-toggle');
document.getElementById("dropdownMenuLink").disabled = false;
if (document.getElementById("dropdownMenuLink")) document.getElementById("dropdownMenuLink").disabled = false;
} else if ($("#change_branch_list").is(":empty")) {
$('.dropdown-cursor-pointer').attr("disabled", false).css({ cursor: 'auto' });
$('#dropdownMenuLink').removeClass('dropdown-toggle');
document.getElementById("dropdownMenuLink").disabled = true;
if (document.getElementById("dropdownMenuLink")) document.getElementById("dropdownMenuLink").disabled = true;
}
var listBranch = AllbranchOption.concat(branchOption);
$('.load-branch').html(listBranch);
Expand All @@ -614,6 +615,28 @@ PosnicPro = {
$("#branch_name option[value='" + branch_id + "']").attr("selected", true);
$(".select-current-branches").select2().val(branch_id).trigger("change");
},
renderBranchSwitcher: function (data) {
var current = String(PosnicPro.local.get('branch_id_set') || '');
var list = $('#header_branch_list').empty();
var seen = {};
var branches = (data || []).filter(function (branch) {
var id = String(branch.id || '');
if (!id || seen[id]) return false;
seen[id] = true;
return true;
});
$('#header_branch_switcher').prop('hidden', branches.length < 2 || (typeof billingWindowId !== 'undefined' && !!billingWindowId));
$('#header_current_branch').text(PosnicPro.local.get('branchname') || 'Branch');
branches.forEach(function (branch) {
var active = String(branch.id) === current;
if (active) $('#header_current_branch').text(branch.branch_name);
var item = $('<button type="button" class="dropdown-item" style="white-space:normal"></button>')
.text(branch.branch_name || 'Branch').prop('disabled', active).toggleClass('active', active);
if (active) item.attr('aria-current', 'true');
item.on('click', function () { PosnicPro.branches.showChange(String(branch.id)); });
list.append(item);
});
},
getBranchTaxList: function () {
var data = {
tax_group: 'all'
Expand Down
Loading
Loading