feat: create shared-code workspace for common code
Create a new shared-code workspace containing common code shared between web-app and email-worker. This reduces code duplication and ensures consistency across workspaces. Structure: - Root package.json defines workspaces (web-app, email-worker, shared-code) - shared-code contains db-types.ts and shareChecksum.ts - Configured as internal npm package (@evidencija-rezija/shared-code) - No build step required (TypeScript source consumed directly) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
15746
package-lock.json
generated
Normal file
15746
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "evidencija-rezija-monorepo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Property management and utility bills tracking monorepo",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"web-app",
|
||||||
|
"email-worker",
|
||||||
|
"shared-code"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"install:all": "npm install",
|
||||||
|
"build:web-app": "npm run build --workspace=web-app",
|
||||||
|
"build:email-worker": "npm run build --workspace=email-worker",
|
||||||
|
"dev:web-app": "npm run dev --workspace=web-app",
|
||||||
|
"dev:email-worker": "npm run start --workspace=email-worker"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"property-management",
|
||||||
|
"utility-bills",
|
||||||
|
"monorepo"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
14
shared-code/package.json
Normal file
14
shared-code/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@evidencija-rezija/shared-code",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shared code for web-app and email-worker",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"mongodb": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
144
shared-code/src/db-types.ts
Normal file
144
shared-code/src/db-types.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { unsubscribe } from "diagnostics_channel";
|
||||||
|
|
||||||
|
export interface FileAttachment {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileType: string;
|
||||||
|
fileLastModified: number;
|
||||||
|
fileContentsBase64: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface YearMonth {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** User settings data */
|
||||||
|
export interface UserSettings {
|
||||||
|
/** user's ID */
|
||||||
|
userId: string;
|
||||||
|
/** whether enableshow IBAN payment instructions in monthly statement */
|
||||||
|
enableIbanPayment?: boolean | null;
|
||||||
|
/** owner name */
|
||||||
|
ownerName?: string | null;
|
||||||
|
/** owner street */
|
||||||
|
ownerStreet?: string | null;
|
||||||
|
/** owner town */
|
||||||
|
ownerTown?: string | null;
|
||||||
|
/** owner IBAN */
|
||||||
|
ownerIBAN?: string | null;
|
||||||
|
/** currency (ISO 4217) */
|
||||||
|
currency?: string | null;
|
||||||
|
/** whether to enable Revolut payment instructions in monthly statement */
|
||||||
|
enableRevolutPayment?: boolean | null;
|
||||||
|
/** owner Revolut payment link */
|
||||||
|
ownerRevolutProfileName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum EmailStatus {
|
||||||
|
/** Email is not yet verified - recipient has not yet confirmed their email address */
|
||||||
|
Unverified = "unverified",
|
||||||
|
/** Email is not yet verified - a verification request has been sent */
|
||||||
|
VerificationPending = "verification-pending",
|
||||||
|
/** sending of verification email failed */
|
||||||
|
VerificationFailed = "verification-failed",
|
||||||
|
/** Email is verified and is in good standing: emails are being successfully delivered */
|
||||||
|
Verified = "verified",
|
||||||
|
/** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */
|
||||||
|
Unsubscribed = "unsubscribed"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bill object in the form returned by MongoDB */
|
||||||
|
export interface BillingLocation {
|
||||||
|
_id: string;
|
||||||
|
/** user's ID */
|
||||||
|
userId: string;
|
||||||
|
/** user's email */
|
||||||
|
userEmail?: string | null;
|
||||||
|
/** name of the location */
|
||||||
|
name: string;
|
||||||
|
/** billing period year and month */
|
||||||
|
yearMonth: YearMonth;
|
||||||
|
/** array of bills */
|
||||||
|
bills: Bill[];
|
||||||
|
/** (optional) notes */
|
||||||
|
notes: string|null;
|
||||||
|
|
||||||
|
/** (optional) method for showing payment instructions to tenant */
|
||||||
|
tenantPaymentMethod?: "none" | "iban" | "revolut" | null;
|
||||||
|
|
||||||
|
/** (optional) type of proof of payment attachment */
|
||||||
|
proofOfPaymentType: "none" | "combined" | "per-bill";
|
||||||
|
|
||||||
|
/** (optional) tenant name */
|
||||||
|
tenantName?: string | null;
|
||||||
|
/** (optional) tenant street */
|
||||||
|
tenantStreet?: string | null;
|
||||||
|
/** (optional) tenant town */
|
||||||
|
tenantTown?: string | null;
|
||||||
|
/** (optional) tenant email */
|
||||||
|
tenantEmail?: string | null;
|
||||||
|
/** (optional) tenant email status */
|
||||||
|
tenantEmailStatus?: EmailStatus | null;
|
||||||
|
/** (optional) whether to automatically notify tenant */
|
||||||
|
billFwdEnabled?: boolean | null;
|
||||||
|
/** (optional) bill forwarding strategy */
|
||||||
|
billFwdStrategy?: "when-payed" | "when-attached" | null;
|
||||||
|
/** (optional) bill forwarding status */
|
||||||
|
billFwdStatus?: "pending" | "sent" | "failed" | null;
|
||||||
|
/** (optional) whether to automatically send rent notification */
|
||||||
|
rentDueNotificationEnabled?: boolean | null;
|
||||||
|
/** (optional) day of month when rent is due (1-31) */
|
||||||
|
rentDueDay?: number | null;
|
||||||
|
/** (optional) when was the rent due notification sent */
|
||||||
|
rentDueNotificationStatus?: "sent" | "failed" | null;
|
||||||
|
/** (optional) monthly rent amount in cents */
|
||||||
|
rentAmount?: number | null;
|
||||||
|
/** (optional) whether the location has been seen by tenant */
|
||||||
|
seenByTenantAt?: Date | null;
|
||||||
|
/** (optional) utility bills proof of payment attachment */
|
||||||
|
utilBillsProofOfPayment?: FileAttachment|null;
|
||||||
|
/** (optional) rent proof of payment attachment */
|
||||||
|
rentProofOfPayment?: FileAttachment|null;
|
||||||
|
/** (optional) share link expiry timestamp */
|
||||||
|
shareTTL?: Date;
|
||||||
|
/** (optional) when tenant first visited the share link */
|
||||||
|
shareFirstVisitedAt?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum BilledTo {
|
||||||
|
Tenant = "tenant",
|
||||||
|
Landlord = "landlord"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bill basic data */
|
||||||
|
export interface Bill {
|
||||||
|
_id: string;
|
||||||
|
/** bill name */
|
||||||
|
name: string;
|
||||||
|
/** is the bill paid */
|
||||||
|
paid: boolean;
|
||||||
|
/** who is billed for the bill */
|
||||||
|
billedTo?: BilledTo;
|
||||||
|
/** payed amount amount in cents */
|
||||||
|
payedAmount?: number | null;
|
||||||
|
/** attached document (optional) */
|
||||||
|
attachment?: FileAttachment|null;
|
||||||
|
/**
|
||||||
|
* true if there an attachment
|
||||||
|
* @description this field enables us to send this info to the client without sending large attachment - it's an optimization
|
||||||
|
*/
|
||||||
|
hasAttachment?: boolean;
|
||||||
|
/** (optional) notes */
|
||||||
|
notes?: string|null;
|
||||||
|
/**
|
||||||
|
* (optional) image data containing PDF471 bar code
|
||||||
|
* @deprecated LEGACY FIELD - use hub3aText instead
|
||||||
|
* */
|
||||||
|
barcodeImage?:string;
|
||||||
|
/** (optional) HUB-3A text for generating PDF417 bar code */
|
||||||
|
hub3aText?:string;
|
||||||
|
/** (optional) proof of payment attachment */
|
||||||
|
proofOfPayment?: FileAttachment|null;
|
||||||
|
};
|
||||||
5
shared-code/src/index.ts
Normal file
5
shared-code/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Database types
|
||||||
|
export * from './db-types';
|
||||||
|
|
||||||
|
// Share checksum utilities
|
||||||
|
export * from './shareChecksum';
|
||||||
86
shared-code/src/shareChecksum.ts
Normal file
86
shared-code/src/shareChecksum.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checksum length in hex characters (16 chars = 64 bits of entropy)
|
||||||
|
*/
|
||||||
|
export const CHECKSUM_LENGTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate share link checksum for location
|
||||||
|
* Uses HMAC-SHA256 for cryptographic integrity
|
||||||
|
*
|
||||||
|
* SECURITY: Prevents location ID enumeration while allowing stateless validation
|
||||||
|
*/
|
||||||
|
export function generateShareChecksum(locationId: string): string {
|
||||||
|
const secret = process.env.SHARE_LINK_SECRET;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('SHARE_LINK_SECRET environment variable not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', secret)
|
||||||
|
.update(locationId)
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, CHECKSUM_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate share link checksum
|
||||||
|
* Uses constant-time comparison to prevent timing attacks
|
||||||
|
*
|
||||||
|
* @param locationId - The location ID from URL
|
||||||
|
* @param providedChecksum - The checksum from URL
|
||||||
|
* @returns true if checksum is valid
|
||||||
|
*/
|
||||||
|
export function validateShareChecksum(
|
||||||
|
locationId: string,
|
||||||
|
providedChecksum: string
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
const expectedChecksum = generateShareChecksum(locationId);
|
||||||
|
|
||||||
|
// Convert to buffers for timing-safe comparison
|
||||||
|
const expected = Buffer.from(expectedChecksum);
|
||||||
|
const provided = Buffer.from(providedChecksum);
|
||||||
|
|
||||||
|
// Length check (prevents timing attack on different lengths)
|
||||||
|
if (expected.length !== provided.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison (prevents timing attacks)
|
||||||
|
return crypto.timingSafeEqual(expected, provided);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate combined location ID with checksum appended
|
||||||
|
* @param locationId - The MongoDB location ID (24 chars)
|
||||||
|
* @returns Combined ID: locationId + checksum (40 chars total)
|
||||||
|
*/
|
||||||
|
export function generateShareId(locationId: string): string {
|
||||||
|
const checksum = generateShareChecksum(locationId);
|
||||||
|
return locationId + checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract location ID and checksum from combined share ID
|
||||||
|
* @param shareId - Combined ID (locationId + checksum)
|
||||||
|
* @returns Object with locationId and checksum, or null if invalid format
|
||||||
|
*/
|
||||||
|
export function extractShareId(shareId: string): { locationId: string; checksum: string } | null {
|
||||||
|
// MongoDB ObjectID is 24 chars, checksum is 16 chars = 40 total
|
||||||
|
const expectedLength = 24 + CHECKSUM_LENGTH;
|
||||||
|
|
||||||
|
if (shareId.length !== expectedLength) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationId = shareId.substring(0, 24);
|
||||||
|
const checksum = shareId.substring(24);
|
||||||
|
|
||||||
|
return { locationId, checksum };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user