db.ts 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598
  1. import { randomBytes, scryptSync } from 'node:crypto'
  2. import { Client } from 'pg'
  3. /**
  4. * A look into the database the suite seeded, for the few things a browser
  5. * cannot see. Mail is not one of them any more: what the app posts is read
  6. * back from the sink in `support/mail.ts`, which is what a person would see.
  7. * What is left is the secret behind a two-factor QR code.
  8. *
  9. * Plain pg rather than the app's Prisma client: the tests run in Playwright's
  10. * process, which has no adapter wired up, and one query does not need one.
  11. */
  12. async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
  13. const url = process.env.E2E_DATABASE_URL
  14. if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
  15. const db = new Client({ connectionString: url })
  16. await db.connect()
  17. try {
  18. return await fn(db)
  19. } finally {
  20. await db.end()
  21. }
  22. }
  23. /** The workshop the seeded owner belongs to. */
  24. export async function ownerOrganizationId(email = 'demo@torqvoice.com'): Promise<string> {
  25. return withDb(async (db) => {
  26. const result = await db.query<{ organizationId: string }>(
  27. `select m."organizationId"
  28. from organization_members m
  29. join users u on u.id = m."userId"
  30. where u.email = $1
  31. limit 1`,
  32. [email]
  33. )
  34. const id = result.rows[0]?.organizationId
  35. if (!id) throw new Error(`no organization for ${email}`)
  36. return id
  37. })
  38. }
  39. /** The workshop a signed-up account ended up owning, by the address it used. */
  40. export async function organizationIdFor(email: string): Promise<string> {
  41. return ownerOrganizationId(email)
  42. }
  43. /**
  44. * Everything a save from the invoice designer writes, as it stands now.
  45. *
  46. * The designer does not edit one record: it writes the workshop's live
  47. * layout, its palette and which design is in use, and that changes every
  48. * invoice printed afterwards — including the ones the pricing and parity
  49. * specs pin to the cent. Saving also graduates an organization from the
  50. * classic pre-designer sheet to the designer's, which is not something a
  51. * test may leave behind it. So the state is taken before and put back after.
  52. */
  53. export interface InvoiceDesignState {
  54. organizationId: string
  55. settings: { key: string; value: string }[]
  56. designIds: string[]
  57. }
  58. export async function invoiceDesignState(): Promise<InvoiceDesignState> {
  59. const organizationId = await ownerOrganizationId()
  60. return withDb(async (db) => {
  61. const settings = await db.query<{ key: string; value: string }>(
  62. `select key, value from app_settings where "organizationId" = $1 and key like 'invoice.%'`,
  63. [organizationId]
  64. )
  65. const designs = await db.query<{ id: string }>(
  66. `select id from document_designs where "organizationId" = $1`,
  67. [organizationId]
  68. )
  69. return {
  70. organizationId,
  71. settings: settings.rows,
  72. designIds: designs.rows.map((row) => row.id),
  73. }
  74. })
  75. }
  76. /** Puts the workshop back exactly as `invoiceDesignState` found it. */
  77. export async function restoreInvoiceDesignState(state: InvoiceDesignState): Promise<void> {
  78. const keys = state.settings.map((row) => row.key)
  79. await withDb(async (db) => {
  80. // Anything the designer added goes; anything it changed goes back.
  81. await db.query(
  82. `delete from app_settings
  83. where "organizationId" = $1 and key like 'invoice.%' and not (key = any($2::text[]))`,
  84. [state.organizationId, keys]
  85. )
  86. for (const row of state.settings) {
  87. await db.query(
  88. `update app_settings set value = $3 where "organizationId" = $1 and key = $2`,
  89. [state.organizationId, row.key, row.value]
  90. )
  91. }
  92. await db.query(
  93. `delete from document_designs
  94. where "organizationId" = $1 and not (id = any($2::text[]))`,
  95. [state.organizationId, state.designIds]
  96. )
  97. })
  98. }
  99. /**
  100. * Addresses of the seeded workshop's own records, for the tests that check a
  101. * different workshop cannot reach them. Read straight from the database
  102. * because the point is to ask for them as an outsider: going through the app
  103. * to find them first would need the very access under test.
  104. */
  105. export interface TenantFixtures {
  106. organizationId: string
  107. vehicleId: string
  108. serviceRecordId: string
  109. customerId: string
  110. quoteId: string
  111. /**
  112. * Words that belong to this workshop and nobody else. A cross-tenant page
  113. * can answer 200 and render an empty shell, which is a refusal too, so the
  114. * test asks whether any of these reached the screen rather than what the
  115. * status code was.
  116. */
  117. vehiclePlate: string
  118. customerName: string
  119. quoteNumber: string
  120. }
  121. /** A customer and a quote, each taken whole so its id and its words agree. */
  122. async function pairs(
  123. db: Client,
  124. organizationId: string
  125. ): Promise<Pick<TenantFixtures, 'customerId' | 'customerName' | 'quoteId' | 'quoteNumber'>> {
  126. const customer = await db.query<{ id: string; name: string }>(
  127. `select id, name from customers where "organizationId" = $1 order by "createdAt", id limit 1`,
  128. [organizationId]
  129. )
  130. if (!customer.rows[0]) throw new Error('the seeded workshop has no customer')
  131. const quote = await db.query<{ id: string; quoteNumber: string }>(
  132. `select id, "quoteNumber" from quotes
  133. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  134. order by "createdAt", id limit 1`,
  135. [organizationId]
  136. )
  137. if (!quote.rows[0]) throw new Error('the seeded workshop has no numbered quote')
  138. return {
  139. customerId: customer.rows[0].id,
  140. customerName: customer.rows[0].name,
  141. quoteId: quote.rows[0].id,
  142. quoteNumber: quote.rows[0].quoteNumber,
  143. }
  144. }
  145. export async function seededTenantFixtures(): Promise<TenantFixtures> {
  146. const organizationId = await ownerOrganizationId()
  147. return withDb(async (db) => {
  148. /**
  149. * A vehicle and one of its own jobs, from one row.
  150. *
  151. * Two queries answered this before, and on a database the suite had been
  152. * run against they happened to agree. On a fresh seed they did not, and
  153. * the job of one vehicle opened under the id of another draws a page with
  154. * nothing on it.
  155. *
  156. * The organisation comes off the vehicle: `service_records.organizationId`
  157. * is nullable and the seed leaves it null, scoping a job by the vehicle it
  158. * sits on.
  159. */
  160. const pair = await db.query<{
  161. vehicleId: string
  162. serviceRecordId: string
  163. licensePlate: string
  164. }>(
  165. `select v.id as "vehicleId", s.id as "serviceRecordId", v."licensePlate"
  166. from service_records s
  167. join vehicles v on v.id = s."vehicleId"
  168. where coalesce(s."organizationId", v."organizationId") = $1
  169. and v."licensePlate" is not null and v."licensePlate" <> ''
  170. order by s."createdAt"
  171. limit 1`,
  172. [organizationId]
  173. )
  174. const job = pair.rows[0]
  175. if (!job) throw new Error('the seeded workshop has no work order on a plated vehicle')
  176. return {
  177. organizationId,
  178. vehicleId: job.vehicleId,
  179. serviceRecordId: job.serviceRecordId,
  180. vehiclePlate: job.licensePlate,
  181. // Id and words from one row each, for the same reason the vehicle and
  182. // its job come from one row: `limit 1` without an order is not a
  183. // promise, and two queries for "a customer" can answer with two
  184. // different customers. That way round the id opens one record and the
  185. // name that is searched for on it belongs to another.
  186. ...(await pairs(db, organizationId)),
  187. }
  188. })
  189. }
  190. /**
  191. * Any work order belonging to a given workshop, for the tests that point one
  192. * workshop's credential at another's records. A workshop that has just been
  193. * opened has a few of its own from onboarding, which is what makes a
  194. * freshly signed-up account a usable target.
  195. */
  196. export async function foreignServiceRecordId(organizationId: string): Promise<string> {
  197. return withDb(async (db) => {
  198. const result = await db.query<{ id: string }>(
  199. `select s.id
  200. from service_records s
  201. join vehicles v on v.id = s."vehicleId"
  202. where coalesce(s."organizationId", v."organizationId") = $1
  203. order by s."createdAt"
  204. limit 1`,
  205. [organizationId]
  206. )
  207. const id = result.rows[0]?.id
  208. if (!id) throw new Error(`no work order in organization ${organizationId}`)
  209. return id
  210. })
  211. }
  212. /** The stored (encrypted) TOTP secret of a user, or null when 2FA is not set up. */
  213. export async function storedTwoFactorSecret(email: string): Promise<string | null> {
  214. return withDb(async (db) => {
  215. const result = await db.query<{ secret: string }>(
  216. `select tf.secret from two_factor tf join users u on u.id = tf."userId" where u.email = $1`,
  217. [email]
  218. )
  219. return result.rows[0]?.secret ?? null
  220. })
  221. }
  222. export interface StockedPart {
  223. id: string
  224. name: string
  225. /** What the ledger says it has on hand right now. */
  226. quantity: number
  227. }
  228. /**
  229. * A seeded inventory part with enough on hand to be consumed by a job, and
  230. * whose name is distinctive enough to search for in the picker.
  231. *
  232. * The part is chosen rather than created, because what is under test is the
  233. * path a workshop actually walks: pick a stocked part, use it, and watch the
  234. * count fall.
  235. */
  236. export async function stockedPart(organizationId: string, atLeast = 10): Promise<StockedPart> {
  237. return withDb(async (db) => {
  238. const result = await db.query<StockedPart>(
  239. `select id, name, quantity
  240. from inventory_parts
  241. where "organizationId" = $1 and quantity >= $2
  242. order by quantity desc, name
  243. limit 1`,
  244. [organizationId, atLeast]
  245. )
  246. const part = result.rows[0]
  247. if (!part) throw new Error(`no inventory part with ${atLeast} or more on hand`)
  248. return { ...part, quantity: Number(part.quantity) }
  249. })
  250. }
  251. /** What one inventory part has on hand. */
  252. export async function partQuantity(partId: string): Promise<number> {
  253. return withDb(async (db) => {
  254. const result = await db.query<{ quantity: number }>(
  255. `select quantity from inventory_parts where id = $1`,
  256. [partId]
  257. )
  258. if (!result.rows[0]) throw new Error(`no inventory part ${partId}`)
  259. return Number(result.rows[0].quantity)
  260. })
  261. }
  262. /** Set a part's quantity outright, to put the seed back as it was found. */
  263. export async function setPartQuantity(partId: string, quantity: number): Promise<void> {
  264. await withDb((db) =>
  265. db.query(`update inventory_parts set quantity = $2 where id = $1`, [partId, quantity])
  266. )
  267. }
  268. export interface StockMovement {
  269. delta: number
  270. quantityAfter: number
  271. reason: string
  272. serviceRecordId: string | null
  273. }
  274. /**
  275. * The ledger for one part, oldest first. Every movement is a row: the count on
  276. * the part is only ever the running total of these, which is why a spec that
  277. * checks stock checks both.
  278. */
  279. export async function stockMovements(
  280. partId: string,
  281. serviceRecordId?: string
  282. ): Promise<StockMovement[]> {
  283. return withDb(async (db) => {
  284. const result = await db.query<StockMovement>(
  285. `select delta, "quantityAfter", reason, "serviceRecordId"
  286. from stock_movements
  287. where "inventoryPartId" = $1
  288. and ($2::text is null or "serviceRecordId" = $2)
  289. order by "createdAt", id`,
  290. [partId, serviceRecordId ?? null]
  291. )
  292. return result.rows.map((row) => ({
  293. ...row,
  294. delta: Number(row.delta),
  295. quantityAfter: Number(row.quantityAfter),
  296. }))
  297. })
  298. }
  299. /** When a reminder is due, as the instant that was stored for it. */
  300. export async function reminderDueDate(title: string): Promise<Date> {
  301. return withDb(async (db) => {
  302. const result = await db.query<{ dueDate: Date }>(
  303. `select "dueDate" from reminders where title = $1 order by "createdAt" desc limit 1`,
  304. [title]
  305. )
  306. const due = result.rows[0]?.dueDate
  307. if (!due) throw new Error(`no reminder titled "${title}" with a due date`)
  308. return new Date(due)
  309. })
  310. }
  311. /** Removes the reminders a spec made, whatever state the page was left in. */
  312. export async function deleteRemindersTitled(title: string): Promise<void> {
  313. await withDb((db) => db.query(`delete from reminders where title = $1`, [title]))
  314. }
  315. /**
  316. * The newest file on a work order, as the app stored its address.
  317. *
  318. * A spec that needs a file belonging to one workshop uploads one and reads it
  319. * back here. Looking for a seeded one instead only worked on a database the
  320. * attachment spec had already run against.
  321. */
  322. export async function latestAttachmentUrl(serviceRecordId: string): Promise<string> {
  323. return withDb(async (db) => {
  324. const result = await db.query<{ fileUrl: string }>(
  325. `select "fileUrl" from service_attachments
  326. where "serviceRecordId" = $1 and "fileUrl" like '/api/protected/files/%'
  327. order by "createdAt" desc
  328. limit 1`,
  329. [serviceRecordId]
  330. )
  331. const url = result.rows[0]?.fileUrl
  332. if (!url) throw new Error(`no stored file on work order ${serviceRecordId}`)
  333. return url
  334. })
  335. }
  336. /**
  337. * Backdates a job's scheduled start to an hour ago. A new work order is
  338. * booked into the shop's next free slot, often tomorrow, and the financial
  339. * reports run up to the present moment, so a job made by a spec is not in
  340. * this year's tax report until it is moved into the past.
  341. */
  342. export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
  343. await withDb((db) =>
  344. db.query(
  345. `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
  346. [serviceRecordId]
  347. )
  348. )
  349. }
  350. /**
  351. * Email templates a spec made, gone again, and every kind back on its
  352. * built-in preset.
  353. *
  354. * The gallery's own delete is what a workshop uses and one test walks it, but
  355. * a file that fails halfway must not leave the workshop sending mail designed
  356. * by a test: the pointer is an `email.template.<kind>` setting, and a
  357. * template row it names is what the resolver prefers over the preset.
  358. */
  359. export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
  360. await withDb(async (db) => {
  361. await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
  362. await db.query(
  363. `delete from app_settings
  364. where key like 'email.template.%'
  365. and value not in (select 'design:' || id from email_templates)`
  366. )
  367. })
  368. }
  369. /** The names of the templates saved for one kind of mail. */
  370. export async function emailTemplateNames(kind: string): Promise<string[]> {
  371. return withDb(async (db) => {
  372. const result = await db.query<{ name: string }>(
  373. `select name from email_templates where kind = $1 order by "createdAt"`,
  374. [kind]
  375. )
  376. return result.rows.map((row) => row.name)
  377. })
  378. }
  379. /**
  380. * Whose car it is, and where to write to them.
  381. *
  382. * The customer of the vehicle a spec is working on, not the first customer in
  383. * the workshop: a message sent from a job goes to the owner of that car, so a
  384. * spec waiting on another customer's mailbox waits forever.
  385. */
  386. export async function customerOfVehicle(
  387. vehicleId: string
  388. ): Promise<{ name: string; email: string }> {
  389. return withDb(async (db) => {
  390. const result = await db.query<{ name: string; email: string }>(
  391. `select c.name, c.email
  392. from vehicles v
  393. join customers c on c.id = v."customerId"
  394. where v.id = $1`,
  395. [vehicleId]
  396. )
  397. const customer = result.rows[0]
  398. if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
  399. return customer
  400. })
  401. }
  402. /** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
  403. export async function connectionStatus(connectorId: string): Promise<string | null> {
  404. const organizationId = await ownerOrganizationId()
  405. return withDb(async (db) => {
  406. const result = await db.query<{ status: string }>(
  407. `select status from integration_connections
  408. where "organizationId" = $1 and "connectorId" = $2`,
  409. [organizationId, connectorId]
  410. )
  411. return result.rows[0]?.status ?? null
  412. })
  413. }
  414. /**
  415. * Every connection a spec made to a vendor, gone. The payment specs connect
  416. * Stripe and PayPal to the seeded workshop, and a connection left behind puts
  417. * pay buttons on every invoice the rest of the suite shares.
  418. */
  419. export async function forgetConnections(connectorIds: string[]): Promise<void> {
  420. const organizationId = await ownerOrganizationId()
  421. await withDb((db) =>
  422. db.query(
  423. `delete from integration_connections
  424. where "organizationId" = $1 and "connectorId" = any($2::text[])`,
  425. [organizationId, connectorIds]
  426. )
  427. )
  428. }
  429. export interface RecordedPayment {
  430. amount: number
  431. provider: string | null
  432. method: string
  433. externalId: string | null
  434. }
  435. /** The money recorded against one work order, oldest first. */
  436. export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
  437. return withDb(async (db) => {
  438. const result = await db.query<RecordedPayment>(
  439. `select amount, provider, method, "externalId" from payments
  440. where "serviceRecordId" = $1
  441. order by "createdAt", id`,
  442. [serviceRecordId]
  443. )
  444. return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
  445. })
  446. }
  447. /**
  448. * Writes a vendor payment row straight into the table, bypassing the app.
  449. *
  450. * For the one question only the database can answer: whether it refuses a
  451. * second row for a payment it already holds. Returns the Postgres error code
  452. * when the insert is refused, or null when it went in.
  453. */
  454. export async function insertVendorPaymentRow(row: {
  455. serviceRecordId: string
  456. provider: string
  457. externalId: string
  458. amount: number
  459. }): Promise<string | null> {
  460. return withDb(async (db) => {
  461. try {
  462. await db.query(
  463. `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
  464. values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
  465. [row.amount, row.provider, row.externalId, row.serviceRecordId]
  466. )
  467. return null
  468. } catch (error) {
  469. return (error as { code?: string }).code ?? 'unknown'
  470. }
  471. })
  472. }
  473. /**
  474. * How many rows one invoice holds for one vendor payment.
  475. *
  476. * Counted against the invoice as well as the id: a vendor's id means one
  477. * payment on one invoice, and a count across the whole table also finds any
  478. * other invoice that happens to carry the same id, which is not a duplicate.
  479. */
  480. export async function vendorPaymentRows(
  481. serviceRecordId: string,
  482. externalId: string
  483. ): Promise<number> {
  484. return withDb(async (db) => {
  485. const result = await db.query<{ n: number }>(
  486. `select count(*)::int as n from payments
  487. where "serviceRecordId" = $1 and "externalId" = $2`,
  488. [serviceRecordId, externalId]
  489. )
  490. return result.rows[0]?.n ?? 0
  491. })
  492. }
  493. /** Removes the rows a spec wrote for one vendor payment. */
  494. export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
  495. await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
  496. }
  497. /** The id of the user signed up with an address. */
  498. export async function userIdFor(email: string): Promise<string> {
  499. return withDb(async (db) => {
  500. const result = await db.query<{ id: string }>(
  501. `select id from users where lower(email) = lower($1)`,
  502. [email]
  503. )
  504. const id = result.rows[0]?.id
  505. if (!id) throw new Error(`no user with ${email}`)
  506. return id
  507. })
  508. }
  509. /**
  510. * Writes customers straight into a workshop, as if it had typed them in.
  511. *
  512. * For reaching a plan limit without twenty trips through a form: what is
  513. * under test is the one customer past the limit, and that one goes through
  514. * the app. These are real customers, not sample ones, so they count.
  515. */
  516. export async function insertCustomers(
  517. organizationId: string,
  518. userId: string,
  519. count: number,
  520. prefix: string
  521. ): Promise<void> {
  522. await withDb((db) =>
  523. db.query(
  524. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  525. select md5(random()::text || clock_timestamp()::text || n), $3 || ' ' || n, $2, $1, now()
  526. from generate_series(1, $4::int) as n`,
  527. [organizationId, userId, prefix, count]
  528. )
  529. )
  530. }
  531. /** Every customer row a workshop holds, sample ones included. */
  532. export async function customerRows(organizationId: string): Promise<number> {
  533. return withDb(async (db) => {
  534. const result = await db.query<{ n: number }>(
  535. `select count(*)::int as n from customers where "organizationId" = $1`,
  536. [organizationId]
  537. )
  538. return result.rows[0]?.n ?? 0
  539. })
  540. }
  541. /** Team invitations a workshop has sent. */
  542. export async function teamInvitations(organizationId: string): Promise<number> {
  543. return withDb(async (db) => {
  544. const result = await db.query<{ n: number }>(
  545. `select count(*)::int as n from team_invitations where "organizationId" = $1`,
  546. [organizationId]
  547. )
  548. return result.rows[0]?.n ?? 0
  549. })
  550. }
  551. /**
  552. * Puts a workshop on an active Pro subscription, as a paid checkout would.
  553. * Returns the plan's id so the spec can take it away again.
  554. */
  555. export async function giveProPlan(
  556. organizationId: string,
  557. stripe?: { subscriptionId: string; customerId: string }
  558. ): Promise<string> {
  559. return withDb(async (db) => {
  560. const plan = await db.query<{ id: string }>(
  561. `insert into subscription_plans (id, name, price, "updatedAt")
  562. values (md5(random()::text || clock_timestamp()::text), 'E2E Pro', 0, now())
  563. returning id`
  564. )
  565. const planId = plan.rows[0].id
  566. // With Stripe ids the row looks like a real purchase, which is what the
  567. // manage-subscription card and its buttons are shown for.
  568. await db.query(
  569. `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt",
  570. "stripeSubscriptionId", "stripeCustomerId")
  571. values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now(), $3, $4)`,
  572. [organizationId, planId, stripe?.subscriptionId ?? null, stripe?.customerId ?? null]
  573. )
  574. return planId
  575. })
  576. }
  577. /** Flags a subscription as ending at the period end, as a cancel through torqvoice.com would. */
  578. export async function setCancelAtPeriodEnd(organizationId: string, value: boolean): Promise<void> {
  579. await withDb((db) =>
  580. db.query(`update subscriptions set "cancelAtPeriodEnd" = $2 where "organizationId" = $1`, [
  581. organizationId,
  582. value,
  583. ])
  584. )
  585. }
  586. /** Takes a subscription and its plan away again. */
  587. export async function removePlan(organizationId: string, planId: string): Promise<void> {
  588. await withDb(async (db) => {
  589. await db.query(`delete from subscriptions where "organizationId" = $1`, [organizationId])
  590. await db.query(`delete from subscription_plans where id = $1`, [planId])
  591. })
  592. }
  593. export interface PersonRecord {
  594. /** How many users hold the address: more than one is two people where there should be one. */
  595. users: number
  596. /** How each of them can sign in: `credential` for a password, `google`. */
  597. providers: string[]
  598. emailVerified: boolean
  599. }
  600. /** Who holds an address, and how they can sign in. */
  601. export async function personWithEmail(email: string): Promise<PersonRecord> {
  602. return withDb(async (db) => {
  603. const users = await db.query<{ id: string; emailVerified: boolean }>(
  604. `select id, "emailVerified" from users where lower(email) = lower($1)`,
  605. [email]
  606. )
  607. const providers = await db.query<{ providerId: string }>(
  608. `select a."providerId" from accounts a join users u on u.id = a."userId"
  609. where lower(u.email) = lower($1) order by a."providerId"`,
  610. [email]
  611. )
  612. return {
  613. users: users.rows.length,
  614. providers: providers.rows.map((row) => row.providerId),
  615. emailVerified: users.rows.some((row) => row.emailVerified),
  616. }
  617. })
  618. }
  619. /** Every vehicle row a workshop holds. */
  620. export async function vehicleRows(organizationId: string): Promise<number> {
  621. return withDb(async (db) => {
  622. const result = await db.query<{ n: number }>(
  623. `select count(*)::int as n from vehicles where "organizationId" = $1`,
  624. [organizationId]
  625. )
  626. return result.rows[0]?.n ?? 0
  627. })
  628. }
  629. /** One of a workshop's settings as stored, or null when it was never saved. */
  630. export async function workshopSetting(organizationId: string, key: string): Promise<string | null> {
  631. return withDb(async (db) => {
  632. const result = await db.query<{ value: string }>(
  633. `select value from app_settings where "organizationId" = $1 and key = $2`,
  634. [organizationId, key]
  635. )
  636. return result.rows[0]?.value ?? null
  637. })
  638. }
  639. /**
  640. * A vehicle registry connected to a workshop, active, the way the header's
  641. * plate lookup looks for one. No keys: nothing is looked up, only offered.
  642. */
  643. export async function connectRegistry(
  644. organizationId: string,
  645. userId: string,
  646. connectorId: string
  647. ): Promise<void> {
  648. await withDb((db) =>
  649. db.query(
  650. `insert into integration_connections
  651. (id, "organizationId", "connectorId", status, "createdById", "updatedAt")
  652. values ($1, $2, $3, 'active', $4, now())`,
  653. [`e2e-${connectorId}-${Date.now()}`, organizationId, connectorId, userId]
  654. )
  655. )
  656. }
  657. export async function disconnectRegistry(
  658. organizationId: string,
  659. connectorId: string
  660. ): Promise<void> {
  661. await withDb((db) =>
  662. db.query(
  663. `delete from integration_connections where "organizationId" = $1 and "connectorId" = $2`,
  664. [organizationId, connectorId]
  665. )
  666. )
  667. }
  668. /** The id of a workshop's customer with exactly this name. */
  669. export async function customerIdNamed(organizationId: string, name: string): Promise<string> {
  670. return withDb(async (db) => {
  671. const result = await db.query<{ id: string }>(
  672. `select id from customers where "organizationId" = $1 and name = $2`,
  673. [organizationId, name]
  674. )
  675. const id = result.rows[0]?.id
  676. if (!id) throw new Error(`no customer named ${name}`)
  677. return id
  678. })
  679. }
  680. // ─── The security specs ──────────────────────────────────────────────────────
  681. /**
  682. * A custom role carrying every action on every subject the app knows, and no
  683. * admin standing. It is the sharpest test of "logged in is not allowed": a
  684. * member with this role passes every `requiredPermissions` check there is,
  685. * and the owner-only and admin-only actions have to refuse them anyway.
  686. */
  687. export async function createRoleWithEveryPermission(
  688. organizationId: string,
  689. name: string
  690. ): Promise<string> {
  691. const subjects = [
  692. 'dashboard',
  693. 'vehicles',
  694. 'customers',
  695. 'work_orders',
  696. 'quotes',
  697. 'services',
  698. 'billing',
  699. 'inventory',
  700. 'labor_presets',
  701. 'inspections',
  702. 'tire_hotel',
  703. 'reports',
  704. 'settings',
  705. 'work_board',
  706. 'ai_assistant',
  707. 'time_tracking',
  708. ]
  709. const actions = ['create', 'read', 'update', 'delete', 'manage']
  710. return withDb(async (db) => {
  711. const role = await db.query<{ id: string }>(
  712. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  713. values (gen_random_uuid()::text, $1, false, $2, now(), now())
  714. returning id`,
  715. [name, organizationId]
  716. )
  717. const roleId = role.rows[0].id
  718. for (const subject of subjects) {
  719. for (const action of actions) {
  720. await db.query(
  721. `insert into permissions (id, action, subject, "roleId")
  722. values (gen_random_uuid()::text, $1, $2, $3)`,
  723. [action, subject, roleId]
  724. )
  725. }
  726. }
  727. return roleId
  728. })
  729. }
  730. /** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
  731. export async function setMembership(
  732. email: string,
  733. organizationId: string,
  734. membership: { roleId: string | null; role: 'member' | 'admin' }
  735. ): Promise<void> {
  736. await withDb((db) =>
  737. db.query(
  738. `update organization_members m
  739. set "roleId" = $3, role = $4
  740. from users u
  741. where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
  742. [email, organizationId, membership.roleId, membership.role]
  743. )
  744. )
  745. }
  746. /** The credential in a pending invitation, or null when there is none for the address. */
  747. export async function invitationTokenFor(
  748. email: string,
  749. organizationId: string
  750. ): Promise<string | null> {
  751. return withDb(async (db) => {
  752. const result = await db.query<{ token: string }>(
  753. `select token from team_invitations
  754. where email = $1 and "organizationId" = $2 and status = 'pending'`,
  755. [email, organizationId]
  756. )
  757. return result.rows[0]?.token ?? null
  758. })
  759. }
  760. /** How much of the workshop there is, for a test that must find it all still there. */
  761. export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
  762. return withDb(async (db) => {
  763. const counts: Record<string, number> = {}
  764. for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
  765. const result = await db.query<{ n: string }>(
  766. `select count(*)::text as n from ${table} where "organizationId" = $1`,
  767. [organizationId]
  768. )
  769. counts[table] = Number(result.rows[0].n)
  770. }
  771. return counts
  772. })
  773. }
  774. /**
  775. * A file row written straight to the job, bypassing the schema that guards
  776. * the action: what a record carried before the guard existed, or what a
  777. * restore could bring in. The path resolver is the last line for these.
  778. */
  779. export async function insertServiceAttachment(row: {
  780. serviceRecordId: string
  781. fileName: string
  782. fileUrl: string
  783. fileType: string
  784. }): Promise<string> {
  785. return withDb(async (db) => {
  786. const result = await db.query<{ id: string }>(
  787. `insert into service_attachments
  788. (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
  789. values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
  790. returning id`,
  791. [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
  792. )
  793. return result.rows[0].id
  794. })
  795. }
  796. export async function deleteServiceAttachments(ids: string[]): Promise<void> {
  797. await withDb((db) =>
  798. db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
  799. )
  800. }
  801. /** How many file rows carry a name, on any job. */
  802. export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
  803. return withDb(async (db) => {
  804. const result = await db.query<{ n: string }>(
  805. `select count(*)::text as n from service_attachments where "fileName" = $1`,
  806. [fileName]
  807. )
  808. return Number(result.rows[0].n)
  809. })
  810. }
  811. /** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
  812. export async function insertConnection(row: {
  813. organizationId: string
  814. connectorId: string
  815. credentials: string
  816. settings: Record<string, unknown>
  817. createdById: string
  818. }): Promise<string> {
  819. return withDb(async (db) => {
  820. const result = await db.query<{ id: string }>(
  821. `insert into integration_connections
  822. (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
  823. values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
  824. returning id`,
  825. [
  826. row.organizationId,
  827. row.connectorId,
  828. row.credentials,
  829. JSON.stringify(row.settings),
  830. row.createdById,
  831. ]
  832. )
  833. return result.rows[0].id
  834. })
  835. }
  836. /** Inbound text messages with exactly this body, for a workshop. */
  837. export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
  838. return withDb(async (db) => {
  839. const result = await db.query<{ n: string }>(
  840. `select count(*)::text as n from sms_messages
  841. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  842. [organizationId, body]
  843. )
  844. return Number(result.rows[0].n)
  845. })
  846. }
  847. export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
  848. await withDb((db) =>
  849. db.query(
  850. `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  851. [organizationId, body]
  852. )
  853. )
  854. }
  855. // ─── Work order titles ───────────────────────────────────────────────────────
  856. /** What a job is called and numbered, straight from its row. */
  857. export async function serviceRecordNames(
  858. serviceRecordId: string
  859. ): Promise<{ title: string; invoiceNumber: string | null }> {
  860. return withDb(async (db) => {
  861. const result = await db.query<{ title: string; invoiceNumber: string | null }>(
  862. `select title, "invoiceNumber" from service_records where id = $1`,
  863. [serviceRecordId]
  864. )
  865. const row = result.rows[0]
  866. if (!row) throw new Error(`no work order ${serviceRecordId}`)
  867. return row
  868. })
  869. }
  870. /** The words a title template can print about one vehicle and its owner. */
  871. export async function vehicleFacts(vehicleId: string): Promise<{
  872. licensePlate: string | null
  873. make: string
  874. model: string
  875. year: number
  876. vin: string | null
  877. customerName: string | null
  878. }> {
  879. return withDb(async (db) => {
  880. const result = await db.query<{
  881. licensePlate: string | null
  882. make: string
  883. model: string
  884. year: number
  885. vin: string | null
  886. customerName: string | null
  887. }>(
  888. `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
  889. from vehicles v
  890. left join customers c on c.id = v."customerId"
  891. where v.id = $1`,
  892. [vehicleId]
  893. )
  894. const row = result.rows[0]
  895. if (!row) throw new Error(`no vehicle ${vehicleId}`)
  896. return row
  897. })
  898. }
  899. /** Removes a workshop setting so the app falls back to its default for it. */
  900. export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
  901. await withDb((db) =>
  902. db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
  903. organizationId,
  904. key,
  905. ])
  906. )
  907. }
  908. /** Marks the address verified, as clicking the mail's link would. */
  909. export async function markEmailVerified(email: string): Promise<void> {
  910. await withDb((db) =>
  911. db.query(`update users set "emailVerified" = true where lower(email) = lower($1)`, [email])
  912. )
  913. }
  914. export interface MembershipRecord {
  915. id: string
  916. role: string
  917. roleId: string | null
  918. }
  919. /** A person's membership of a workshop, as stored. */
  920. export async function membershipOf(
  921. email: string,
  922. organizationId: string
  923. ): Promise<MembershipRecord> {
  924. return withDb(async (db) => {
  925. const result = await db.query<MembershipRecord>(
  926. `select m.id, m.role, m."roleId" from organization_members m
  927. join users u on u.id = m."userId"
  928. where lower(u.email) = lower($1) and m."organizationId" = $2`,
  929. [email, organizationId]
  930. )
  931. if (!result.rows[0]) throw new Error(`${email} is not a member of ${organizationId}`)
  932. return result.rows[0]
  933. })
  934. }
  935. /** A role that carries the admin switch and nothing else. */
  936. export async function createAdminRole(organizationId: string, name: string): Promise<string> {
  937. return withDb(async (db) => {
  938. const result = await db.query<{ id: string }>(
  939. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  940. values (gen_random_uuid()::text, $1, true, $2, now(), now()) returning id`,
  941. [name, organizationId]
  942. )
  943. return result.rows[0].id
  944. })
  945. }
  946. export async function deleteRoles(ids: string[]): Promise<void> {
  947. if (ids.length === 0) return
  948. await withDb((db) => db.query(`delete from roles where id = any($1::text[])`, [ids]))
  949. }
  950. /** A technician on a workshop's board, made here so the spec owns it. */
  951. export async function insertTechnician(organizationId: string, name: string): Promise<string> {
  952. return withDb(async (db) => {
  953. const result = await db.query<{ id: string }>(
  954. `insert into technicians (id, name, "organizationId", "createdAt", "updatedAt")
  955. values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
  956. [name, organizationId]
  957. )
  958. return result.rows[0].id
  959. })
  960. }
  961. export async function insertWorkBay(organizationId: string, name: string): Promise<string> {
  962. return withDb(async (db) => {
  963. const result = await db.query<{ id: string }>(
  964. `insert into work_bays (id, name, "organizationId", "createdAt", "updatedAt")
  965. values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
  966. [name, organizationId]
  967. )
  968. return result.rows[0].id
  969. })
  970. }
  971. export async function deleteTechnicians(ids: string[]): Promise<void> {
  972. if (ids.length === 0) return
  973. await withDb((db) => db.query(`delete from technicians where id = any($1::text[])`, [ids]))
  974. }
  975. export async function deleteWorkBays(ids: string[]): Promise<void> {
  976. if (ids.length === 0) return
  977. await withDb((db) => db.query(`delete from work_bays where id = any($1::text[])`, [ids]))
  978. }
  979. export interface JobAssignment {
  980. id: string
  981. technicianId: string | null
  982. workBayId: string | null
  983. }
  984. /** A job's technician and bay as stored, by its id. */
  985. export async function jobAssignment(serviceRecordId: string): Promise<JobAssignment> {
  986. return withDb(async (db) => {
  987. const result = await db.query<JobAssignment>(
  988. `select id, "technicianId", "workBayId" from service_records where id = $1`,
  989. [serviceRecordId]
  990. )
  991. if (!result.rows[0]) throw new Error(`no job ${serviceRecordId}`)
  992. return result.rows[0]
  993. })
  994. }
  995. /** How many jobs a vehicle has, before and after an attempt to add one. */
  996. export async function jobCount(vehicleId: string): Promise<number> {
  997. return withDb(async (db) => {
  998. const result = await db.query<{ n: string }>(
  999. `select count(*)::text as n from service_records where "vehicleId" = $1`,
  1000. [vehicleId]
  1001. )
  1002. return Number(result.rows[0].n)
  1003. })
  1004. }
  1005. /** Inbound WhatsApp messages with exactly this body, for a workshop. */
  1006. export async function inboundWhatsappCount(organizationId: string, body: string): Promise<number> {
  1007. return withDb(async (db) => {
  1008. const result = await db.query<{ n: string }>(
  1009. `select count(*)::text as n from whatsapp_messages
  1010. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  1011. [organizationId, body]
  1012. )
  1013. return Number(result.rows[0].n)
  1014. })
  1015. }
  1016. export async function deleteInboundWhatsapp(organizationId: string, body: string): Promise<void> {
  1017. await withDb((db) =>
  1018. db.query(
  1019. `delete from whatsapp_messages where "organizationId" = $1 and direction = 'inbound' and body like $2`,
  1020. [organizationId, `${body}%`]
  1021. )
  1022. )
  1023. }
  1024. /** Open sessions a person has, however many browsers and phones that is. */
  1025. export async function sessionCountFor(email: string): Promise<number> {
  1026. return withDb(async (db) => {
  1027. const result = await db.query<{ n: string }>(
  1028. `select count(*)::text as n from sessions s join users u on u.id = s."userId"
  1029. where lower(u.email) = lower($1) and s."expiresAt" > now()`,
  1030. [email]
  1031. )
  1032. return Number(result.rows[0].n)
  1033. })
  1034. }
  1035. /** Device rows a person has whose user agent mentions `needle`. */
  1036. export async function deviceCountFor(email: string, needle: string): Promise<number> {
  1037. return withDb(async (db) => {
  1038. const result = await db.query<{ n: string }>(
  1039. `select count(*)::text as n from user_devices d join users u on u.id = d."userId"
  1040. where lower(u.email) = lower($1) and d."userAgent" like $2`,
  1041. [email, `%${needle}%`]
  1042. )
  1043. return Number(result.rows[0].n)
  1044. })
  1045. }
  1046. /**
  1047. * Matches better-auth's scrypt parameters, the same way the seed does, so a
  1048. * password written here is accepted by the sign-in form.
  1049. */
  1050. function hashPassword(password: string): string {
  1051. const N = 16384
  1052. const r = 16
  1053. const p = 1
  1054. const salt = randomBytes(16).toString('hex')
  1055. const key = scryptSync(password.normalize('NFKC'), salt, 64, { N, r, p, maxmem: 128 * N * r * 2 })
  1056. return `${salt}:${key.toString('hex')}`
  1057. }
  1058. export interface PlantedWorkshop {
  1059. userId: string
  1060. organizationId: string
  1061. }
  1062. /**
  1063. * A second tenant, put straight into the database.
  1064. *
  1065. * A self-hosted install opens one workshop; every later sign-up is told to
  1066. * ask for an invitation. A spec that needs a second, separate workshop to
  1067. * prove isolation therefore cannot sign one up and has to plant it: a
  1068. * verified person with a password, and a workshop they own.
  1069. */
  1070. export async function plantWorkshop(input: {
  1071. name: string
  1072. email: string
  1073. password: string
  1074. workshopName: string
  1075. }): Promise<PlantedWorkshop> {
  1076. return withDb(async (db) => {
  1077. const user = await db.query<{ id: string }>(
  1078. `insert into users (id, name, email, "emailVerified", "termsAcceptedAt", "createdAt", "updatedAt")
  1079. values (md5(random()::text || clock_timestamp()::text), $1, $2, true, now(), now(), now())
  1080. returning id`,
  1081. [input.name, input.email.toLowerCase()]
  1082. )
  1083. const userId = user.rows[0].id
  1084. await db.query(
  1085. `insert into accounts (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt")
  1086. values (md5(random()::text || clock_timestamp()::text), $1, 'credential', $1, $2, now(), now())`,
  1087. [userId, hashPassword(input.password)]
  1088. )
  1089. const org = await db.query<{ id: string }>(
  1090. `insert into organizations (id, name, "createdAt", "updatedAt")
  1091. values (md5(random()::text || clock_timestamp()::text), $1, now(), now())
  1092. returning id`,
  1093. [input.workshopName]
  1094. )
  1095. const organizationId = org.rows[0].id
  1096. await db.query(
  1097. `insert into organization_members (id, role, "userId", "organizationId")
  1098. values (md5(random()::text || clock_timestamp()::text), 'owner', $1, $2)`,
  1099. [userId, organizationId]
  1100. )
  1101. return { userId, organizationId }
  1102. })
  1103. }
  1104. /** Removes a person and, through the cascade, their memberships and sessions. */
  1105. export async function deletePersonWithEmail(email: string): Promise<void> {
  1106. await withDb((db) => db.query(`delete from users where lower(email) = lower($1)`, [email]))
  1107. }
  1108. /** How many workshops the install has. */
  1109. export async function organizationCount(): Promise<number> {
  1110. return withDb(async (db) => {
  1111. const result = await db.query<{ count: string }>(
  1112. `select count(*)::text as count from organizations`
  1113. )
  1114. return Number(result.rows[0].count)
  1115. })
  1116. }
  1117. /**
  1118. * One customer, one vehicle and one work order in a workshop, for a spec
  1119. * that needs a job to point at. A planted workshop has none of the sample
  1120. * data onboarding would have given it.
  1121. */
  1122. export async function plantJob(
  1123. organizationId: string,
  1124. userId: string,
  1125. title: string
  1126. ): Promise<{ serviceRecordId: string; vehicleId: string }> {
  1127. return withDb(async (db) => {
  1128. const customer = await db.query<{ id: string }>(
  1129. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  1130. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1131. returning id`,
  1132. [`${title} customer`, userId, organizationId]
  1133. )
  1134. const vehicle = await db.query<{ id: string }>(
  1135. `insert into vehicles (id, make, model, year, "userId", "organizationId", "customerId", "updatedAt")
  1136. values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2020, $2, $3, $4, now())
  1137. returning id`,
  1138. [title, userId, organizationId, customer.rows[0].id]
  1139. )
  1140. const vehicleId = vehicle.rows[0].id
  1141. const job = await db.query<{ id: string }>(
  1142. `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
  1143. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
  1144. returning id`,
  1145. [title, vehicleId, organizationId]
  1146. )
  1147. return { serviceRecordId: job.rows[0].id, vehicleId }
  1148. })
  1149. }
  1150. // ─── Notifications ───────────────────────────────────────────────────────────
  1151. export interface PlantedNotification {
  1152. type: string
  1153. title: string
  1154. message: string
  1155. entityType: string
  1156. entityId: string
  1157. entityUrl: string
  1158. }
  1159. /**
  1160. * A notification written straight into the bell, with the address the code
  1161. * that raises it builds. Planting it rather than provoking it lets a spec
  1162. * follow links whose trigger needs a provider the harness cannot play (an
  1163. * inbound SMS, a Telegram webhook), and also links already stored in the old
  1164. * shape, which the pages still have to honour.
  1165. */
  1166. export async function plantNotification(
  1167. organizationId: string,
  1168. n: PlantedNotification
  1169. ): Promise<string> {
  1170. return withDb(async (db) => {
  1171. const result = await db.query<{ id: string }>(
  1172. `insert into notifications (id, type, title, message, "entityType", "entityId", "entityUrl", read, "organizationId", "createdAt")
  1173. values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, false, $7, now())
  1174. returning id`,
  1175. [n.type, n.title, n.message, n.entityType, n.entityId, n.entityUrl, organizationId]
  1176. )
  1177. return result.rows[0].id
  1178. })
  1179. }
  1180. export async function deleteNotifications(ids: string[]): Promise<void> {
  1181. if (ids.length === 0) return
  1182. await withDb((db) => db.query('delete from notifications where id = any($1)', [ids]))
  1183. }
  1184. /** An inbound message on a customer's thread, as the webhook would have stored it. */
  1185. export async function plantInboundMessage(
  1186. channel: 'sms' | 'telegram',
  1187. organizationId: string,
  1188. customerId: string,
  1189. body: string
  1190. ): Promise<void> {
  1191. await withDb((db) =>
  1192. channel === 'sms'
  1193. ? db.query(
  1194. `insert into sms_messages (id, direction, "fromNumber", "toNumber", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1195. values (md5(random()::text || clock_timestamp()::text), 'inbound', '+4790000000', '+4790000001', $1, 'received', $2, $3, now(), now())`,
  1196. [body, organizationId, customerId]
  1197. )
  1198. : db.query(
  1199. `insert into telegram_messages (id, direction, "chatId", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
  1200. values (md5(random()::text || clock_timestamp()::text), 'inbound', '777000', $1, 'received', $2, $3, now(), now())`,
  1201. [body, organizationId, customerId]
  1202. )
  1203. )
  1204. }
  1205. /**
  1206. * Links a customer to a Telegram chat and hands back what was there before.
  1207. * A real inbound Telegram message only ever comes from a linked chat, and the
  1208. * conversation shows nothing but "not connected yet" without one.
  1209. */
  1210. export async function linkTelegramChat(
  1211. customerId: string,
  1212. chatId: string | null
  1213. ): Promise<string | null> {
  1214. return withDb(async (db) => {
  1215. const before = await db.query<{ telegramChatId: string | null }>(
  1216. 'select "telegramChatId" from customers where id = $1',
  1217. [customerId]
  1218. )
  1219. await db.query('update customers set "telegramChatId" = $1 where id = $2', [chatId, customerId])
  1220. return before.rows[0]?.telegramChatId ?? null
  1221. })
  1222. }
  1223. export async function deleteMessagesWithBody(body: string): Promise<void> {
  1224. await withDb(async (db) => {
  1225. await db.query('delete from sms_messages where body = $1', [body])
  1226. await db.query('delete from telegram_messages where body = $1', [body])
  1227. })
  1228. }
  1229. /** A vehicle job with the customer it belongs to, taken from one row so the ids agree. */
  1230. export async function jobWithCustomer(organizationId: string): Promise<{
  1231. vehicleId: string
  1232. serviceRecordId: string
  1233. customerId: string
  1234. customerName: string
  1235. }> {
  1236. return withDb(async (db) => {
  1237. const result = await db.query<{
  1238. vehicleId: string
  1239. serviceRecordId: string
  1240. customerId: string
  1241. customerName: string
  1242. }>(
  1243. `select v.id as "vehicleId", s.id as "serviceRecordId", c.id as "customerId", c.name as "customerName"
  1244. from service_records s
  1245. join vehicles v on v.id = s."vehicleId"
  1246. join customers c on c.id = v."customerId"
  1247. where s."organizationId" = $1
  1248. order by s."createdAt" asc
  1249. limit 1`,
  1250. [organizationId]
  1251. )
  1252. const row = result.rows[0]
  1253. if (!row) throw new Error('the seeded workshop has no vehicle job with a customer')
  1254. return row
  1255. })
  1256. }
  1257. export interface PlantedVehicleFiles {
  1258. vehicleImage: string
  1259. jobPhoto: string
  1260. /** A tire set's photo, also on the job as a tire hotel copy. */
  1261. tireSetPhoto: string
  1262. statusVideo: string
  1263. inspectionPhoto?: string
  1264. quoteDocument: string
  1265. /** A URL naming another workshop, as a row restored from its backup can. */
  1266. foreignPhoto: string
  1267. }
  1268. export interface PlantedVehicle {
  1269. vehicleId: string
  1270. serviceRecordId: string
  1271. tireSetId: string
  1272. quoteId: string
  1273. inspected: boolean
  1274. }
  1275. /**
  1276. * A vehicle with every kind of file that can go with it, written straight
  1277. * into the database so a spec knows exactly which rows point at which file:
  1278. * its image; a job with a photo, a status report video and the copy of a tire
  1279. * set's photo; an inspection with a photo on one item (when the workshop has
  1280. * a template to hang it on); and, pointing at the same vehicle but not
  1281. * deleted with it, a stored tire set and a quote with a document.
  1282. */
  1283. export async function plantVehicleWithFiles(
  1284. organizationId: string,
  1285. userId: string,
  1286. files: PlantedVehicleFiles,
  1287. label: string
  1288. ): Promise<PlantedVehicle> {
  1289. return withDb(async (db) => {
  1290. const id = () => randomBytes(12).toString('hex')
  1291. const vehicleId = id()
  1292. const serviceRecordId = id()
  1293. const tireSetId = id()
  1294. const quoteId = id()
  1295. await db.query(
  1296. `insert into vehicles (id, make, model, year, "userId", "organizationId", "imageUrl", "updatedAt")
  1297. values ($1, 'E2E', $2, 2020, $3, $4, $5, now())`,
  1298. [vehicleId, label, userId, organizationId, files.vehicleImage]
  1299. )
  1300. await db.query(
  1301. `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
  1302. values ($1, $2, $3, $4, now())`,
  1303. [serviceRecordId, `${label} job`, vehicleId, organizationId]
  1304. )
  1305. await db.query(
  1306. `insert into tire_sets (id, "organizationId", "userId", "vehicleId", "updatedAt")
  1307. values ($1, $2, $3, $4, now())`,
  1308. [tireSetId, organizationId, userId, vehicleId]
  1309. )
  1310. await db.query(
  1311. `insert into tire_set_attachments (id, "organizationId", "tireSetId", "fileName", "fileUrl", "fileType", "fileSize")
  1312. values ($1, $2, $3, 'rim.jpg', $4, 'image/jpeg', 10)`,
  1313. [id(), organizationId, tireSetId, files.tireSetPhoto]
  1314. )
  1315. for (const [fileUrl, category] of [
  1316. [files.jobPhoto, 'image'],
  1317. [files.tireSetPhoto, 'tire_hotel'],
  1318. [files.foreignPhoto, 'image'],
  1319. ]) {
  1320. await db.query(
  1321. `insert into service_attachments (id, "serviceRecordId", "fileName", "fileUrl", "fileType", "fileSize", category)
  1322. values ($1, $2, 'photo.jpg', $3, 'image/jpeg', 10, $4)`,
  1323. [id(), serviceRecordId, fileUrl, category]
  1324. )
  1325. }
  1326. await db.query(
  1327. `insert into status_reports (id, "publicToken", "organizationId", "serviceRecordId", "videoUrl", "updatedAt")
  1328. values ($1, $2, $3, $4, $5, now())`,
  1329. [id(), id(), organizationId, serviceRecordId, files.statusVideo]
  1330. )
  1331. await db.query(
  1332. `insert into quotes (id, title, "userId", "organizationId", "vehicleId", "updatedAt")
  1333. values ($1, $2, $3, $4, $5, now())`,
  1334. [quoteId, `${label} quote`, userId, organizationId, vehicleId]
  1335. )
  1336. await db.query(
  1337. `insert into quote_attachments (id, "quoteId", "fileName", "fileUrl", "fileType", "fileSize")
  1338. values ($1, $2, 'estimate.pdf', $3, 'application/pdf', 10)`,
  1339. [id(), quoteId, files.quoteDocument]
  1340. )
  1341. let inspected = false
  1342. const template = await db.query<{ id: string }>(
  1343. `select id from inspection_templates where "organizationId" = $1 limit 1`,
  1344. [organizationId]
  1345. )
  1346. if (files.inspectionPhoto && template.rows[0]) {
  1347. const inspectionId = id()
  1348. await db.query(
  1349. `insert into inspections (id, "vehicleId", "organizationId", "templateId", "updatedAt")
  1350. values ($1, $2, $3, $4, now())`,
  1351. [inspectionId, vehicleId, organizationId, template.rows[0].id]
  1352. )
  1353. await db.query(
  1354. `insert into inspection_items (id, "inspectionId", name, section, "imageUrls")
  1355. values ($1, $2, 'Brakes', 'Checks', $3)`,
  1356. [id(), inspectionId, [files.inspectionPhoto]]
  1357. )
  1358. inspected = true
  1359. }
  1360. return { vehicleId, serviceRecordId, tireSetId, quoteId, inspected }
  1361. })
  1362. }
  1363. /** Removes what `plantVehicleWithFiles` made that its spec did not delete. */
  1364. export async function removePlantedVehicle(planted: PlantedVehicle): Promise<void> {
  1365. await withDb(async (db) => {
  1366. await db.query('delete from quotes where id = $1', [planted.quoteId])
  1367. await db.query('delete from tire_sets where id = $1', [planted.tireSetId])
  1368. await db.query('delete from inspections where "vehicleId" = $1', [planted.vehicleId])
  1369. await db.query('delete from vehicles where id = $1', [planted.vehicleId])
  1370. })
  1371. }
  1372. /**
  1373. * Every permission refusal logged for one person since a moment in time.
  1374. *
  1375. * `withAuth` writes an `auth.permissionDenied` row whenever a role is short of
  1376. * what an action asked for, which makes the audit log the one place that says
  1377. * what a page quietly wanted and did not get. A refusal on a page the role is
  1378. * meant to reach is, by definition, a bug: the page renders anyway, falls back
  1379. * to a built-in default, and says nothing about it.
  1380. *
  1381. * The write is fire-and-forget, so give it a moment to land before counting.
  1382. */
  1383. export async function permissionDenialsFor(email: string, since: Date): Promise<string[]> {
  1384. return withDb(async (db) => {
  1385. const result = await db.query<{ message: string }>(
  1386. `select coalesce(a.message, a.action) as message
  1387. from audit_logs a
  1388. join users u on u.id = a."userId"
  1389. where lower(u.email) = lower($1)
  1390. and a.action = 'auth.permissionDenied'
  1391. and a.timestamp >= $2
  1392. order by a.timestamp`,
  1393. [email, since]
  1394. )
  1395. return result.rows.map((row) => row.message)
  1396. })
  1397. }
  1398. /**
  1399. * Sets one of a workshop's settings directly, returning what was there before
  1400. * (null when the key had never been saved), so a spec can put it back.
  1401. *
  1402. * The row needs an owner: `app_settings.userId` is not nullable, so a key the
  1403. * workshop has never saved is attributed to whoever owns the workshop.
  1404. */
  1405. export async function setWorkshopSetting(
  1406. organizationId: string,
  1407. key: string,
  1408. value: string
  1409. ): Promise<string | null> {
  1410. return withDb(async (db) => {
  1411. const before = await db.query<{ value: string }>(
  1412. `select value from app_settings where "organizationId" = $1 and key = $2`,
  1413. [organizationId, key]
  1414. )
  1415. await db.query(
  1416. `insert into app_settings (id, key, value, "userId", "organizationId")
  1417. values (gen_random_uuid()::text, $2, $3,
  1418. (select "userId" from organization_members
  1419. where "organizationId" = $1 and role = 'owner' limit 1),
  1420. $1)
  1421. on conflict ("organizationId", key) do update set value = excluded.value`,
  1422. [organizationId, key, value]
  1423. )
  1424. return before.rows[0]?.value ?? null
  1425. })
  1426. }
  1427. /**
  1428. * A custom field on one kind of record, made here so the spec owns it.
  1429. *
  1430. * `name` is the key the app stores values under and `label` is what a person
  1431. * reads, so both are stamped: the point of the field is that its label shows
  1432. * up on the record, and a name left over from an earlier run would collide on
  1433. * `(organizationId, name, entityType)`.
  1434. */
  1435. export async function plantCustomField(
  1436. organizationId: string,
  1437. entityType: 'service_record' | 'quote',
  1438. name: string
  1439. ): Promise<string> {
  1440. return withDb(async (db) => {
  1441. const result = await db.query<{ id: string }>(
  1442. `insert into custom_field_definitions
  1443. (id, name, label, "fieldType", "entityType", "sortOrder", "isActive",
  1444. "createdAt", "updatedAt", "userId", "organizationId")
  1445. values (gen_random_uuid()::text, $2, $2, 'text', $3, 0, true, now(), now(),
  1446. (select "userId" from organization_members
  1447. where "organizationId" = $1 and role = 'owner' limit 1),
  1448. $1)
  1449. returning id`,
  1450. [organizationId, name, entityType]
  1451. )
  1452. return result.rows[0].id
  1453. })
  1454. }
  1455. /** Removes planted field definitions, and the values written into them. */
  1456. export async function deleteCustomFields(ids: string[]): Promise<void> {
  1457. if (ids.length === 0) return
  1458. await withDb(async (db) => {
  1459. await db.query(`delete from custom_field_values where "fieldId" = any($1::text[])`, [ids])
  1460. await db.query(`delete from custom_field_definitions where id = any($1::text[])`, [ids])
  1461. })
  1462. }
  1463. /**
  1464. * The value stored in one custom field for one record, or null.
  1465. *
  1466. * Read back to prove a save from the browser reached the database rather than
  1467. * only the input it was typed into.
  1468. */
  1469. export async function customFieldValue(fieldId: string, entityId: string): Promise<string | null> {
  1470. return withDb(async (db) => {
  1471. const result = await db.query<{ value: string }>(
  1472. `select value from custom_field_values where "fieldId" = $1 and "entityId" = $2`,
  1473. [fieldId, entityId]
  1474. )
  1475. return result.rows[0]?.value ?? null
  1476. })
  1477. }
  1478. /**
  1479. * A workshop's own role by name, as `createDefaultRoles` made it.
  1480. *
  1481. * The built-in Member role is what the product really hands somebody at the
  1482. * desk, so a spec about that role has to use that row rather than build an
  1483. * equivalent permission list by hand: a list assembled in the test would keep
  1484. * passing after the real role changed underneath it.
  1485. */
  1486. export async function roleIdNamed(organizationId: string, name: string): Promise<string | null> {
  1487. return withDb(async (db) => {
  1488. const result = await db.query<{ id: string }>(
  1489. `select id from roles where "organizationId" = $1 and name = $2 limit 1`,
  1490. [organizationId, name]
  1491. )
  1492. return result.rows[0]?.id ?? null
  1493. })
  1494. }