feat: add contract extension request functionality

- Implemented  method in  to allow customers to request an extension for expired contracts.
- Added  component in  for users to initiate extension requests.
- Updated  to include logic for handling extension requests for expired contracts.
- Enhanced  to display extension request options and status.
- Created migration to add  and  columns to the contracts table.
- Added unit tests for contract extension request and handling in .
- Defined DTOs for request and extension in .
- Updated types in  to include new fields related to contract extensions.
This commit is contained in:
marshal
2026-09-06 12:33:40 +00:00
parent f225721e89
commit 75b75e3d4e
32 changed files with 1582 additions and 179 deletions

View File

@@ -0,0 +1,99 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customer-requested validity extension of an EXPIRED contract.
*
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
* add days on the backoffice detail page and the contract returns to the status
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
* read) now stash that status in `status_before_expiry`, mirroring
* `status_before_suspension`; rows expired before this column existed fall
* back to the kind's resting status on extension.
*
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
* resolves every registry key against `iam.permissions` at boot and throws on
* a missing row, so the catalog row must exist wherever the registry ships.
* The grant is copied from whoever already holds `contracts:suspend` — the
* registry places both keys on the same desk (marketing), and the position
* seeder only re-syncs presets when SEED_EDR_ORG is set.
*/
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
name = 'ContractExtensionRequest3920000000000';
private static readonly KEY = 'edr_freight_app:contracts:extend';
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
`);
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
);
// Grant wherever suspend is already granted (positions and roles alike).
await queryRunner.query(
`INSERT INTO iam.position_permissions (position_id, permission_id)
SELECT pp.position_id, np.id
FROM iam.position_permissions pp
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.position_permissions x
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
await queryRunner.query(
`INSERT INTO iam.role_permissions (role_id, permission_id)
SELECT rp.role_id, np.id
FROM iam.role_permissions rp
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.role_permissions x
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
}
/** Grants go first, or the delete trips the permission foreign keys. */
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
ContractExtensionRequest3920000000000.KEY,
]);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
);
}
}