fix mode
Build & Push / Pipeline Tests (push) Successful in 1m59s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m40s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m8s
Build & Push / Pipeline Tests (push) Successful in 1m59s
Test / Type Check (all packages) (push) Successful in 52s
Build & Push / Build & Push Docker Image (push) Successful in 3m40s
Test / API Unit Tests (push) Successful in 1m12s
Test / Homepage Unit Tests (push) Successful in 44s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 43s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m8s
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Scope Discipline Rules
|
||||||
|
|
||||||
|
These rules override any general instinct to "improve while I'm in there." Follow them on every task, no exceptions.
|
||||||
|
|
||||||
|
## Before touching any code
|
||||||
|
|
||||||
|
1. Restate the task in one sentence: what behavior must change, and what the expected outcome is.
|
||||||
|
2. Identify the smallest set of files/functions responsible for that behavior. This is your **allowed scope**. Everything else is **protected**.
|
||||||
|
3. Read the relevant code before editing it. Do not edit based on assumptions about how it probably works.
|
||||||
|
|
||||||
|
## The hard rule: ask before expanding scope
|
||||||
|
|
||||||
|
If, while working, you find that:
|
||||||
|
- a file outside your allowed scope needs to change,
|
||||||
|
- a dependency needs to be added/updated,
|
||||||
|
- a test needs modification,
|
||||||
|
- an unrelated bug is blocking you,
|
||||||
|
- or a "cleaner" implementation would touch more than the minimum,
|
||||||
|
|
||||||
|
**stop and ask me before making that change.** Explain:
|
||||||
|
- what you were trying to do,
|
||||||
|
- why the fix requires going outside the original scope,
|
||||||
|
- exactly what you want to change and where.
|
||||||
|
|
||||||
|
Wait for my answer. Do not proceed on your own judgment, even if you're confident it's correct or trivial.
|
||||||
|
|
||||||
|
This applies even to small things (renaming a variable for clarity, fixing a typo in an unrelated comment, reformatting a block you had to scroll past). If it's not required to satisfy the request, ask first.
|
||||||
|
|
||||||
|
## While editing
|
||||||
|
|
||||||
|
- Make the fewest-line, fewest-file change that correctly satisfies the request.
|
||||||
|
- Preserve existing naming, structure, patterns, and formatting. Match the codebase's existing style, don't impose your own.
|
||||||
|
- Never run project-wide formatters/linters-with-autofix/import-organizers as a side effect of a small change.
|
||||||
|
- Never touch tests except to add new ones that validate the requested behavior — and only after confirming that's in scope.
|
||||||
|
- Treat any uncommitted/staged changes already in the working tree as off-limits. Don't revert, reset, or absorb them into your edit.
|
||||||
|
|
||||||
|
## Before reporting done
|
||||||
|
|
||||||
|
Review your own diff, file by file, line by line. For anything you can't justify with "this was required by the explicit request," revert it.
|
||||||
|
|
||||||
|
Then report:
|
||||||
|
- **Files changed** — list, with a one-line reason each tied directly to the request.
|
||||||
|
- **Scope confirmation** — explicitly state: "No files, dependencies, tests, or config outside this list were modified."
|
||||||
|
- **Anything you noticed but didn't touch** — unrelated bugs, tech debt, cleanup opportunities. Mention them, don't fix them.
|
||||||
|
|
||||||
|
## If the task genuinely can't be done without expanding scope
|
||||||
|
|
||||||
|
Say so plainly, explain what would need to change and why, and wait for confirmation. Don't silently do the bigger version, and don't pretend a partial/incorrect fix is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Default when uncertain: don't make the change, ask instead.**
|
||||||
@@ -30,6 +30,12 @@ describe('customer schema contracts', () => {
|
|||||||
q: 'aya',
|
q: 'aya',
|
||||||
flagged: 'true',
|
flagged: 'true',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(listQuerySchema.parse({ pageSize: '100', search: 'aya' })).toEqual({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
search: 'aya',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects impossible pagination and unsafe customer field lengths', () => {
|
it('rejects impossible pagination and unsafe customer field lengths', () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const customerSchema = z.object({
|
|||||||
|
|
||||||
export const listQuerySchema = paginationSchema.extend({
|
export const listQuerySchema = paginationSchema.extend({
|
||||||
q: z.string().max(100).optional(),
|
q: z.string().max(100).optional(),
|
||||||
|
search: z.string().max(100).optional(),
|
||||||
flagged: z.enum(['true', 'false']).optional(),
|
flagged: z.enum(['true', 'false']).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ describe('customer.service boundary behavior', () => {
|
|||||||
expect(result.meta).toEqual({ total: 41, page: 3, pageSize: 10, totalPages: 5 })
|
expect(result.meta).toEqual({ total: 41, page: 3, pageSize: 10, totalPages: 5 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts dashboard search query alias for customer lists', async () => {
|
||||||
|
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as never)
|
||||||
|
|
||||||
|
await service.listCustomers('company_1', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
search: ' aya ',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(repo.findMany).toHaveBeenCalledWith({
|
||||||
|
companyId: 'company_1',
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: 'aya', mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: 'aya', mode: 'insensitive' } },
|
||||||
|
{ email: { contains: 'aya', mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}, 0, 100)
|
||||||
|
})
|
||||||
|
|
||||||
it('normalizes customer date fields and swallows async license validation failures after create', async () => {
|
it('normalizes customer date fields and swallows async license validation failures after create', async () => {
|
||||||
vi.mocked(repo.create).mockResolvedValue({ id: 'customer_1', email: 'renter@example.test' } as never)
|
vi.mocked(repo.create).mockResolvedValue({ id: 'customer_1', email: 'renter@example.test' } as never)
|
||||||
vi.mocked(validateAndFlagLicense).mockRejectedValue(new Error('provider down') as never)
|
vi.mocked(validateAndFlagLicense).mockRejectedValue(new Error('provider down') as never)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { NotFoundError } from '../../http/errors'
|
|||||||
import { presentCustomer, presentCustomerList } from './customer.presenter'
|
import { presentCustomer, presentCustomerList } from './customer.presenter'
|
||||||
import * as repo from './customer.repo'
|
import * as repo from './customer.repo'
|
||||||
|
|
||||||
export async function listCustomers(companyId: string, query: { page?: number; pageSize?: number; q?: string; flagged?: string }) {
|
export async function listCustomers(companyId: string, query: { page?: number; pageSize?: number; q?: string; search?: string; flagged?: string }) {
|
||||||
const page = query.page ?? 1
|
const page = query.page ?? 1
|
||||||
const pageSize = query.pageSize ?? 20
|
const pageSize = query.pageSize ?? 20
|
||||||
const { q, flagged } = query
|
const { q, search, flagged } = query
|
||||||
const safeQ = q ? q.trim().slice(0, 100) : undefined
|
const safeQ = (q ?? search)?.trim().slice(0, 100) || undefined
|
||||||
const where: any = { companyId }
|
const where: any = { companyId }
|
||||||
if (flagged !== undefined) where.flagged = flagged === 'true'
|
if (flagged !== undefined) where.flagged = flagged === 'true'
|
||||||
if (safeQ) {
|
if (safeQ) {
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ describe('reservation.presenter boundary behavior', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('treats omitted contract and invoice numbers as not generated', () => {
|
||||||
|
expect(buildReservationWorkflow({
|
||||||
|
status: 'DRAFT',
|
||||||
|
extras: {},
|
||||||
|
})).toMatchObject({
|
||||||
|
contractGenerated: false,
|
||||||
|
coreEditable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('marks closed reservations as immutable and exposes close metadata from extras only when strings', () => {
|
it('marks closed reservations as immutable and exposes close metadata from extras only when strings', () => {
|
||||||
expect(buildReservationWorkflow({
|
expect(buildReservationWorkflow({
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ function readAddressField(address: unknown, key: string): string | null {
|
|||||||
|
|
||||||
export function buildReservationWorkflow(reservation: {
|
export function buildReservationWorkflow(reservation: {
|
||||||
status: string
|
status: string
|
||||||
contractNumber: string | null
|
contractNumber?: string | null
|
||||||
invoiceNumber: string | null
|
invoiceNumber?: string | null
|
||||||
extras: unknown
|
extras: unknown
|
||||||
}) {
|
}) {
|
||||||
const extras = parseReservationExtras(reservation.extras)
|
const extras = parseReservationExtras(reservation.extras)
|
||||||
@@ -147,8 +147,8 @@ export function serializeReservationForDashboard<T extends {
|
|||||||
extras: unknown
|
extras: unknown
|
||||||
status: string
|
status: string
|
||||||
source: string
|
source: string
|
||||||
contractNumber: string | null
|
contractNumber?: string | null
|
||||||
invoiceNumber: string | null
|
invoiceNumber?: string | null
|
||||||
paymentStatus?: string | null
|
paymentStatus?: string | null
|
||||||
customer?: DashboardReservationCustomer | null
|
customer?: DashboardReservationCustomer | null
|
||||||
}>(reservation: T): SerializedDashboardReservation<T> {
|
}>(reservation: T): SerializedDashboardReservation<T> {
|
||||||
|
|||||||
@@ -43,7 +43,21 @@ describe('reservation.repo edge queries', () => {
|
|||||||
|
|
||||||
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
|
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
|
||||||
where: { companyId: 'company_1' },
|
where: { companyId: 'company_1' },
|
||||||
include: { vehicle: true, customer: true },
|
include: {
|
||||||
|
vehicle: true,
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
driverLicense: true,
|
||||||
|
dateOfBirth: true,
|
||||||
|
address: true,
|
||||||
|
licenseValidationStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
skip: 20,
|
skip: 20,
|
||||||
take: 10,
|
take: 10,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -9,11 +9,22 @@ const FULL_INCLUDE = {
|
|||||||
damageReports: true,
|
damageReports: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DASHBOARD_LIST_CUSTOMER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
driverLicense: true,
|
||||||
|
dateOfBirth: true,
|
||||||
|
address: true,
|
||||||
|
licenseValidationStatus: true,
|
||||||
|
}
|
||||||
|
|
||||||
export async function findMany(where: any, skip: number, take: number) {
|
export async function findMany(where: any, skip: number, take: number) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
prisma.reservation.findMany({
|
prisma.reservation.findMany({
|
||||||
where,
|
where,
|
||||||
include: { vehicle: true, customer: true },
|
include: { vehicle: true, customer: { select: DASHBOARD_LIST_CUSTOMER_SELECT } },
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
|||||||
@@ -78,6 +78,19 @@ describe('vehicle pricing service boundaries', () => {
|
|||||||
expect(repo.createPricingConfiguration).not.toHaveBeenCalled()
|
expect(repo.createPricingConfiguration).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns transient pricing when pricing migration columns or enums are missing', async () => {
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||||
|
vi.mocked(repo.findPricingConfiguration).mockRejectedValue({
|
||||||
|
code: 'P2022',
|
||||||
|
message: 'The column VehiclePricingMode does not exist',
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await service.getVehiclePricing('vehicle_1', 'company_1')
|
||||||
|
|
||||||
|
expect(result.configuration.id).toBe('transient-vehicle_1')
|
||||||
|
expect(repo.createPricingConfiguration).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects pricing configuration bounds before persisting invalid rates', async () => {
|
it('rejects pricing configuration bounds before persisting invalid rates', async () => {
|
||||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||||
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(config as any)
|
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(config as any)
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ const RULE_PRIORITY: Record<string, number> = {
|
|||||||
function isPricingStorageMissing(error: unknown) {
|
function isPricingStorageMissing(error: unknown) {
|
||||||
if (!error || typeof error !== 'object') return false
|
if (!error || typeof error !== 'object') return false
|
||||||
const candidate = error as { code?: string; message?: string }
|
const candidate = error as { code?: string; message?: string }
|
||||||
|
const message = candidate.message ?? ''
|
||||||
return (
|
return (
|
||||||
candidate.code === 'P2021' ||
|
candidate.code === 'P2021' ||
|
||||||
candidate.message?.includes('vehicle_pricing_configurations') === true ||
|
candidate.code === 'P2022' ||
|
||||||
candidate.message?.includes('vehicle_pricing_rules') === true ||
|
message.includes('vehicle_pricing_configurations') ||
|
||||||
candidate.message?.includes('vehicle_price_history') === true
|
message.includes('vehicle_pricing_rules') ||
|
||||||
|
message.includes('vehicle_price_history') ||
|
||||||
|
message.includes('VehiclePricingMode') ||
|
||||||
|
message.includes('VehiclePricingRuleType') ||
|
||||||
|
message.includes('VehiclePriceChangeSource')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Task: Add Input Validation for User Data Form
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Add length/format/range validation for the fields listed below, enforced at three layers: frontend (UX), backend (source of truth), and database (last line of defense). Do not change unrelated form behavior, styling, or fields not listed here.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
Only touch the files responsible for these fields: the form component(s), the API endpoint(s) that receive this data, the validation schema/middleware, and the DB migration/schema for these columns. If a field doesn't exist yet in the DB/API, flag it and ask before creating new schema — don't assume the shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Rules
|
||||||
|
|
||||||
|
| Field | Type | Rule |
|
||||||
|
|---|---|---|
|
||||||
|
| firstName | string | required, trim, 1–50 chars, Unicode letters/spaces/hyphens/apostrophes allowed |
|
||||||
|
| lastName | string | required, trim, 1–50 chars, same charset as firstName |
|
||||||
|
| email | string | required, trim, max 254 chars, valid email format (use an existing validation library, don't hand-roll regex) |
|
||||||
|
| nationality | enum | required, must be a valid ISO 3166-1 country code (dropdown-backed, not free text) |
|
||||||
|
| phoneNumber | string | required, normalize to E.164 on save (use existing phone validation lib if the project has one; otherwise ask before adding a dependency), max 20 raw input chars |
|
||||||
|
| driverLicense | string | optional unless told otherwise, trim, 5–30 chars, alphanumeric only |
|
||||||
|
| date (generic) | date | must be a valid ISO 8601 date (`YYYY-MM-DD`); reject free text |
|
||||||
|
| address | string | required, trim, 1–255 chars |
|
||||||
|
| idPassport | string | required, trim, 5–30 chars, alphanumeric |
|
||||||
|
| country | enum | required, valid ISO 3166-1 country code (dropdown-backed) |
|
||||||
|
| issuedDate | date | required, ISO 8601, must be ≤ today |
|
||||||
|
| expirationDate | date | required, ISO 8601, must be ≥ issuedDate |
|
||||||
|
| dateTime | datetime | required, store as ISO 8601 with UTC offset — do not store naive local time |
|
||||||
|
| vinNumber | string | required, exactly 17 chars, alphanumeric excluding I/O/Q, uppercase |
|
||||||
|
| city | string | required, trim, 1–85 chars |
|
||||||
|
| amount | number | required, numeric, > 0, max 2 decimal places, reasonable upper bound (ask what "reasonable" means for this use case if not obvious from context — don't guess a business rule) |
|
||||||
|
|
||||||
|
## Cross-field rules
|
||||||
|
- `expirationDate` must be after `issuedDate`.
|
||||||
|
- `issuedDate` must not be in the future.
|
||||||
|
- Do not implement any cross-field rule not listed here without asking first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation steps
|
||||||
|
|
||||||
|
1. **Locate existing validation approach.** Check if the project already uses a validation library (Zod, Yup, Joi, class-validator, pydantic, etc.) or a pattern for form validation. Use whatever already exists — do not introduce a new validation library if one is already in use.
|
||||||
|
2. **Frontend:** add `maxlength`/type-appropriate input constraints and inline error messages matching the rules above. Reuse existing form error-display patterns in the codebase.
|
||||||
|
3. **Backend:** add/extend the validation schema or middleware for the relevant endpoint(s) to enforce the same rules server-side. This is the authoritative check — assume the frontend check can be bypassed.
|
||||||
|
4. **Database:** only if column definitions currently allow unbounded/incorrect lengths, add matching length constraints via migration. Do not touch unrelated columns or tables.
|
||||||
|
5. Normalize country/nationality to ISO codes, phone to E.164, and dates to ISO 8601 at the point of validation/save — not scattered across multiple layers.
|
||||||
|
|
||||||
|
## Explicitly out of scope (ask before doing any of this)
|
||||||
|
- Adding a new third-party validation/phone/country-list library if one doesn't already exist in the project.
|
||||||
|
- Changing the DB schema shape (splitting address into multiple columns, etc.) — flag it as a suggestion instead.
|
||||||
|
- Any field not in the list above.
|
||||||
|
- Deep format validation for driverLicense/idPassport beyond length + charset (these vary too much by issuing country to hardcode safely) — ask if stricter per-country validation is actually wanted.
|
||||||
|
|
||||||
|
## Validation before reporting done
|
||||||
|
- Confirm each rule above has a corresponding check in frontend AND backend (list them field by field in your report).
|
||||||
|
- Confirm existing tests still pass; add new tests only for the fields touched here.
|
||||||
|
- Report any field where the existing DB column type/length conflicts with the rule above, rather than silently changing it.
|
||||||
Reference in New Issue
Block a user