db.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. import { Client } from 'pg'
  2. /**
  3. * A look into the database the suite seeded, for the few things a browser
  4. * cannot see. Mail is not one of them any more: what the app posts is read
  5. * back from the sink in `support/mail.ts`, which is what a person would see.
  6. * What is left is the secret behind a two-factor QR code.
  7. *
  8. * Plain pg rather than the app's Prisma client: the tests run in Playwright's
  9. * process, which has no adapter wired up, and one query does not need one.
  10. */
  11. async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
  12. const url = process.env.E2E_DATABASE_URL
  13. if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
  14. const db = new Client({ connectionString: url })
  15. await db.connect()
  16. try {
  17. return await fn(db)
  18. } finally {
  19. await db.end()
  20. }
  21. }
  22. /** The workshop the seeded owner belongs to. */
  23. export async function ownerOrganizationId(email = 'demo@torqvoice.com'): Promise<string> {
  24. return withDb(async (db) => {
  25. const result = await db.query<{ organizationId: string }>(
  26. `select m."organizationId"
  27. from organization_members m
  28. join users u on u.id = m."userId"
  29. where u.email = $1
  30. limit 1`,
  31. [email]
  32. )
  33. const id = result.rows[0]?.organizationId
  34. if (!id) throw new Error(`no organization for ${email}`)
  35. return id
  36. })
  37. }
  38. /** The workshop a signed-up account ended up owning, by the address it used. */
  39. export async function organizationIdFor(email: string): Promise<string> {
  40. return ownerOrganizationId(email)
  41. }
  42. /**
  43. * Everything a save from the invoice designer writes, as it stands now.
  44. *
  45. * The designer does not edit one record: it writes the workshop's live
  46. * layout, its palette and which design is in use, and that changes every
  47. * invoice printed afterwards — including the ones the pricing and parity
  48. * specs pin to the cent. Saving also graduates an organization from the
  49. * classic pre-designer sheet to the designer's, which is not something a
  50. * test may leave behind it. So the state is taken before and put back after.
  51. */
  52. export interface InvoiceDesignState {
  53. organizationId: string
  54. settings: { key: string; value: string }[]
  55. designIds: string[]
  56. }
  57. export async function invoiceDesignState(): Promise<InvoiceDesignState> {
  58. const organizationId = await ownerOrganizationId()
  59. return withDb(async (db) => {
  60. const settings = await db.query<{ key: string; value: string }>(
  61. `select key, value from app_settings where "organizationId" = $1 and key like 'invoice.%'`,
  62. [organizationId]
  63. )
  64. const designs = await db.query<{ id: string }>(
  65. `select id from document_designs where "organizationId" = $1`,
  66. [organizationId]
  67. )
  68. return {
  69. organizationId,
  70. settings: settings.rows,
  71. designIds: designs.rows.map((row) => row.id),
  72. }
  73. })
  74. }
  75. /** Puts the workshop back exactly as `invoiceDesignState` found it. */
  76. export async function restoreInvoiceDesignState(state: InvoiceDesignState): Promise<void> {
  77. const keys = state.settings.map((row) => row.key)
  78. await withDb(async (db) => {
  79. // Anything the designer added goes; anything it changed goes back.
  80. await db.query(
  81. `delete from app_settings
  82. where "organizationId" = $1 and key like 'invoice.%' and not (key = any($2::text[]))`,
  83. [state.organizationId, keys]
  84. )
  85. for (const row of state.settings) {
  86. await db.query(
  87. `update app_settings set value = $3 where "organizationId" = $1 and key = $2`,
  88. [state.organizationId, row.key, row.value]
  89. )
  90. }
  91. await db.query(
  92. `delete from document_designs
  93. where "organizationId" = $1 and not (id = any($2::text[]))`,
  94. [state.organizationId, state.designIds]
  95. )
  96. })
  97. }
  98. /**
  99. * Addresses of the seeded workshop's own records, for the tests that check a
  100. * different workshop cannot reach them. Read straight from the database
  101. * because the point is to ask for them as an outsider: going through the app
  102. * to find them first would need the very access under test.
  103. */
  104. export interface TenantFixtures {
  105. organizationId: string
  106. vehicleId: string
  107. serviceRecordId: string
  108. customerId: string
  109. quoteId: string
  110. /**
  111. * Words that belong to this workshop and nobody else. A cross-tenant page
  112. * can answer 200 and render an empty shell, which is a refusal too, so the
  113. * test asks whether any of these reached the screen rather than what the
  114. * status code was.
  115. */
  116. vehiclePlate: string
  117. customerName: string
  118. quoteNumber: string
  119. }
  120. export async function seededTenantFixtures(): Promise<TenantFixtures> {
  121. const organizationId = await ownerOrganizationId()
  122. return withDb(async (db) => {
  123. const one = async (sql: string): Promise<string> => {
  124. const result = await db.query<{ id: string }>(sql, [organizationId])
  125. const id = result.rows[0]?.id
  126. if (!id) throw new Error(`the seeded workshop has nothing for: ${sql}`)
  127. return id
  128. }
  129. /**
  130. * A vehicle and one of its own jobs, from one row.
  131. *
  132. * Two queries answered this before, and on a database the suite had been
  133. * run against they happened to agree. On a fresh seed they did not, and
  134. * the job of one vehicle opened under the id of another draws a page with
  135. * nothing on it.
  136. *
  137. * The organisation comes off the vehicle: `service_records.organizationId`
  138. * is nullable and the seed leaves it null, scoping a job by the vehicle it
  139. * sits on.
  140. */
  141. const pair = await db.query<{
  142. vehicleId: string
  143. serviceRecordId: string
  144. licensePlate: string
  145. }>(
  146. `select v.id as "vehicleId", s.id as "serviceRecordId", v."licensePlate"
  147. from service_records s
  148. join vehicles v on v.id = s."vehicleId"
  149. where coalesce(s."organizationId", v."organizationId") = $1
  150. and v."licensePlate" is not null and v."licensePlate" <> ''
  151. order by s."createdAt"
  152. limit 1`,
  153. [organizationId]
  154. )
  155. const job = pair.rows[0]
  156. if (!job) throw new Error('the seeded workshop has no work order on a plated vehicle')
  157. return {
  158. organizationId,
  159. vehicleId: job.vehicleId,
  160. serviceRecordId: job.serviceRecordId,
  161. vehiclePlate: job.licensePlate,
  162. customerId: await one(`select id from customers where "organizationId" = $1 limit 1`),
  163. quoteId: await one(
  164. `select id from quotes
  165. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  166. limit 1`
  167. ),
  168. customerName: await one(
  169. `select name as id from customers where "organizationId" = $1 limit 1`
  170. ),
  171. quoteNumber: await one(
  172. `select "quoteNumber" as id from quotes
  173. where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
  174. limit 1`
  175. ),
  176. }
  177. })
  178. }
  179. /**
  180. * Any work order belonging to a given workshop, for the tests that point one
  181. * workshop's credential at another's records. A workshop that has just been
  182. * opened has a few of its own from onboarding, which is what makes a
  183. * freshly signed-up account a usable target.
  184. */
  185. export async function foreignServiceRecordId(organizationId: string): Promise<string> {
  186. return withDb(async (db) => {
  187. const result = await db.query<{ id: string }>(
  188. `select s.id
  189. from service_records s
  190. join vehicles v on v.id = s."vehicleId"
  191. where coalesce(s."organizationId", v."organizationId") = $1
  192. order by s."createdAt"
  193. limit 1`,
  194. [organizationId]
  195. )
  196. const id = result.rows[0]?.id
  197. if (!id) throw new Error(`no work order in organization ${organizationId}`)
  198. return id
  199. })
  200. }
  201. /** The stored (encrypted) TOTP secret of a user, or null when 2FA is not set up. */
  202. export async function storedTwoFactorSecret(email: string): Promise<string | null> {
  203. return withDb(async (db) => {
  204. const result = await db.query<{ secret: string }>(
  205. `select tf.secret from two_factor tf join users u on u.id = tf."userId" where u.email = $1`,
  206. [email]
  207. )
  208. return result.rows[0]?.secret ?? null
  209. })
  210. }
  211. export interface StockedPart {
  212. id: string
  213. name: string
  214. /** What the ledger says it has on hand right now. */
  215. quantity: number
  216. }
  217. /**
  218. * A seeded inventory part with enough on hand to be consumed by a job, and
  219. * whose name is distinctive enough to search for in the picker.
  220. *
  221. * The part is chosen rather than created, because what is under test is the
  222. * path a workshop actually walks: pick a stocked part, use it, and watch the
  223. * count fall.
  224. */
  225. export async function stockedPart(organizationId: string, atLeast = 10): Promise<StockedPart> {
  226. return withDb(async (db) => {
  227. const result = await db.query<StockedPart>(
  228. `select id, name, quantity
  229. from inventory_parts
  230. where "organizationId" = $1 and quantity >= $2
  231. order by quantity desc, name
  232. limit 1`,
  233. [organizationId, atLeast]
  234. )
  235. const part = result.rows[0]
  236. if (!part) throw new Error(`no inventory part with ${atLeast} or more on hand`)
  237. return { ...part, quantity: Number(part.quantity) }
  238. })
  239. }
  240. /** What one inventory part has on hand. */
  241. export async function partQuantity(partId: string): Promise<number> {
  242. return withDb(async (db) => {
  243. const result = await db.query<{ quantity: number }>(
  244. `select quantity from inventory_parts where id = $1`,
  245. [partId]
  246. )
  247. if (!result.rows[0]) throw new Error(`no inventory part ${partId}`)
  248. return Number(result.rows[0].quantity)
  249. })
  250. }
  251. /** Set a part's quantity outright, to put the seed back as it was found. */
  252. export async function setPartQuantity(partId: string, quantity: number): Promise<void> {
  253. await withDb((db) =>
  254. db.query(`update inventory_parts set quantity = $2 where id = $1`, [partId, quantity])
  255. )
  256. }
  257. export interface StockMovement {
  258. delta: number
  259. quantityAfter: number
  260. reason: string
  261. serviceRecordId: string | null
  262. }
  263. /**
  264. * The ledger for one part, oldest first. Every movement is a row: the count on
  265. * the part is only ever the running total of these, which is why a spec that
  266. * checks stock checks both.
  267. */
  268. export async function stockMovements(
  269. partId: string,
  270. serviceRecordId?: string
  271. ): Promise<StockMovement[]> {
  272. return withDb(async (db) => {
  273. const result = await db.query<StockMovement>(
  274. `select delta, "quantityAfter", reason, "serviceRecordId"
  275. from stock_movements
  276. where "inventoryPartId" = $1
  277. and ($2::text is null or "serviceRecordId" = $2)
  278. order by "createdAt", id`,
  279. [partId, serviceRecordId ?? null]
  280. )
  281. return result.rows.map((row) => ({
  282. ...row,
  283. delta: Number(row.delta),
  284. quantityAfter: Number(row.quantityAfter),
  285. }))
  286. })
  287. }
  288. /** When a reminder is due, as the instant that was stored for it. */
  289. export async function reminderDueDate(title: string): Promise<Date> {
  290. return withDb(async (db) => {
  291. const result = await db.query<{ dueDate: Date }>(
  292. `select "dueDate" from reminders where title = $1 order by "createdAt" desc limit 1`,
  293. [title]
  294. )
  295. const due = result.rows[0]?.dueDate
  296. if (!due) throw new Error(`no reminder titled "${title}" with a due date`)
  297. return new Date(due)
  298. })
  299. }
  300. /** Removes the reminders a spec made, whatever state the page was left in. */
  301. export async function deleteRemindersTitled(title: string): Promise<void> {
  302. await withDb((db) => db.query(`delete from reminders where title = $1`, [title]))
  303. }
  304. /**
  305. * The newest file on a work order, as the app stored its address.
  306. *
  307. * A spec that needs a file belonging to one workshop uploads one and reads it
  308. * back here. Looking for a seeded one instead only worked on a database the
  309. * attachment spec had already run against.
  310. */
  311. export async function latestAttachmentUrl(serviceRecordId: string): Promise<string> {
  312. return withDb(async (db) => {
  313. const result = await db.query<{ fileUrl: string }>(
  314. `select "fileUrl" from service_attachments
  315. where "serviceRecordId" = $1 and "fileUrl" like '/api/protected/files/%'
  316. order by "createdAt" desc
  317. limit 1`,
  318. [serviceRecordId]
  319. )
  320. const url = result.rows[0]?.fileUrl
  321. if (!url) throw new Error(`no stored file on work order ${serviceRecordId}`)
  322. return url
  323. })
  324. }
  325. /**
  326. * Backdates a job's scheduled start to an hour ago. A new work order is
  327. * booked into the shop's next free slot, often tomorrow, and the financial
  328. * reports run up to the present moment, so a job made by a spec is not in
  329. * this year's tax report until it is moved into the past.
  330. */
  331. export async function scheduleServiceRecordInThePast(serviceRecordId: string): Promise<void> {
  332. await withDb((db) =>
  333. db.query(
  334. `update service_records set "startDateTime" = now() - interval '1 hour' where id = $1`,
  335. [serviceRecordId]
  336. )
  337. )
  338. }
  339. /**
  340. * Email templates a spec made, gone again, and every kind back on its
  341. * built-in preset.
  342. *
  343. * The gallery's own delete is what a workshop uses and one test walks it, but
  344. * a file that fails halfway must not leave the workshop sending mail designed
  345. * by a test: the pointer is an `email.template.<kind>` setting, and a
  346. * template row it names is what the resolver prefers over the preset.
  347. */
  348. export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
  349. await withDb(async (db) => {
  350. await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
  351. await db.query(
  352. `delete from app_settings
  353. where key like 'email.template.%'
  354. and value not in (select 'design:' || id from email_templates)`
  355. )
  356. })
  357. }
  358. /** The names of the templates saved for one kind of mail. */
  359. export async function emailTemplateNames(kind: string): Promise<string[]> {
  360. return withDb(async (db) => {
  361. const result = await db.query<{ name: string }>(
  362. `select name from email_templates where kind = $1 order by "createdAt"`,
  363. [kind]
  364. )
  365. return result.rows.map((row) => row.name)
  366. })
  367. }
  368. /**
  369. * Whose car it is, and where to write to them.
  370. *
  371. * The customer of the vehicle a spec is working on, not the first customer in
  372. * the workshop: a message sent from a job goes to the owner of that car, so a
  373. * spec waiting on another customer's mailbox waits forever.
  374. */
  375. export async function customerOfVehicle(
  376. vehicleId: string
  377. ): Promise<{ name: string; email: string }> {
  378. return withDb(async (db) => {
  379. const result = await db.query<{ name: string; email: string }>(
  380. `select c.name, c.email
  381. from vehicles v
  382. join customers c on c.id = v."customerId"
  383. where v.id = $1`,
  384. [vehicleId]
  385. )
  386. const customer = result.rows[0]
  387. if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
  388. return customer
  389. })
  390. }
  391. /** Where a workshop's connection to a vendor stands: active, pending, error, or none at all. */
  392. export async function connectionStatus(connectorId: string): Promise<string | null> {
  393. const organizationId = await ownerOrganizationId()
  394. return withDb(async (db) => {
  395. const result = await db.query<{ status: string }>(
  396. `select status from integration_connections
  397. where "organizationId" = $1 and "connectorId" = $2`,
  398. [organizationId, connectorId]
  399. )
  400. return result.rows[0]?.status ?? null
  401. })
  402. }
  403. /**
  404. * Every connection a spec made to a vendor, gone. The payment specs connect
  405. * Stripe and PayPal to the seeded workshop, and a connection left behind puts
  406. * pay buttons on every invoice the rest of the suite shares.
  407. */
  408. export async function forgetConnections(connectorIds: string[]): Promise<void> {
  409. const organizationId = await ownerOrganizationId()
  410. await withDb((db) =>
  411. db.query(
  412. `delete from integration_connections
  413. where "organizationId" = $1 and "connectorId" = any($2::text[])`,
  414. [organizationId, connectorIds]
  415. )
  416. )
  417. }
  418. export interface RecordedPayment {
  419. amount: number
  420. provider: string | null
  421. method: string
  422. externalId: string | null
  423. }
  424. /** The money recorded against one work order, oldest first. */
  425. export async function paymentsFor(serviceRecordId: string): Promise<RecordedPayment[]> {
  426. return withDb(async (db) => {
  427. const result = await db.query<RecordedPayment>(
  428. `select amount, provider, method, "externalId" from payments
  429. where "serviceRecordId" = $1
  430. order by "createdAt", id`,
  431. [serviceRecordId]
  432. )
  433. return result.rows.map((row) => ({ ...row, amount: Number(row.amount) }))
  434. })
  435. }
  436. /**
  437. * Writes a vendor payment row straight into the table, bypassing the app.
  438. *
  439. * For the one question only the database can answer: whether it refuses a
  440. * second row for a payment it already holds. Returns the Postgres error code
  441. * when the insert is refused, or null when it went in.
  442. */
  443. export async function insertVendorPaymentRow(row: {
  444. serviceRecordId: string
  445. provider: string
  446. externalId: string
  447. amount: number
  448. }): Promise<string | null> {
  449. return withDb(async (db) => {
  450. try {
  451. await db.query(
  452. `insert into payments (id, amount, method, provider, "externalId", "serviceRecordId", "updatedAt")
  453. values (md5(random()::text || clock_timestamp()::text), $1, $2, $2, $3, $4, now())`,
  454. [row.amount, row.provider, row.externalId, row.serviceRecordId]
  455. )
  456. return null
  457. } catch (error) {
  458. return (error as { code?: string }).code ?? 'unknown'
  459. }
  460. })
  461. }
  462. /**
  463. * How many rows one invoice holds for one vendor payment.
  464. *
  465. * Counted against the invoice as well as the id: a vendor's id means one
  466. * payment on one invoice, and a count across the whole table also finds any
  467. * other invoice that happens to carry the same id, which is not a duplicate.
  468. */
  469. export async function vendorPaymentRows(
  470. serviceRecordId: string,
  471. externalId: string
  472. ): Promise<number> {
  473. return withDb(async (db) => {
  474. const result = await db.query<{ n: number }>(
  475. `select count(*)::int as n from payments
  476. where "serviceRecordId" = $1 and "externalId" = $2`,
  477. [serviceRecordId, externalId]
  478. )
  479. return result.rows[0]?.n ?? 0
  480. })
  481. }
  482. /** Removes the rows a spec wrote for one vendor payment. */
  483. export async function deleteVendorPaymentRows(externalId: string): Promise<void> {
  484. await withDb((db) => db.query(`delete from payments where "externalId" = $1`, [externalId]))
  485. }
  486. /** The id of the user signed up with an address. */
  487. export async function userIdFor(email: string): Promise<string> {
  488. return withDb(async (db) => {
  489. const result = await db.query<{ id: string }>(
  490. `select id from users where lower(email) = lower($1)`,
  491. [email]
  492. )
  493. const id = result.rows[0]?.id
  494. if (!id) throw new Error(`no user with ${email}`)
  495. return id
  496. })
  497. }
  498. /**
  499. * Writes customers straight into a workshop, as if it had typed them in.
  500. *
  501. * For reaching a plan limit without twenty trips through a form: what is
  502. * under test is the one customer past the limit, and that one goes through
  503. * the app. These are real customers, not sample ones, so they count.
  504. */
  505. export async function insertCustomers(
  506. organizationId: string,
  507. userId: string,
  508. count: number,
  509. prefix: string
  510. ): Promise<void> {
  511. await withDb((db) =>
  512. db.query(
  513. `insert into customers (id, name, "userId", "organizationId", "updatedAt")
  514. select md5(random()::text || clock_timestamp()::text || n), $3 || ' ' || n, $2, $1, now()
  515. from generate_series(1, $4::int) as n`,
  516. [organizationId, userId, prefix, count]
  517. )
  518. )
  519. }
  520. /** Every customer row a workshop holds, sample ones included. */
  521. export async function customerRows(organizationId: string): Promise<number> {
  522. return withDb(async (db) => {
  523. const result = await db.query<{ n: number }>(
  524. `select count(*)::int as n from customers where "organizationId" = $1`,
  525. [organizationId]
  526. )
  527. return result.rows[0]?.n ?? 0
  528. })
  529. }
  530. /** Team invitations a workshop has sent. */
  531. export async function teamInvitations(organizationId: string): Promise<number> {
  532. return withDb(async (db) => {
  533. const result = await db.query<{ n: number }>(
  534. `select count(*)::int as n from team_invitations where "organizationId" = $1`,
  535. [organizationId]
  536. )
  537. return result.rows[0]?.n ?? 0
  538. })
  539. }
  540. /**
  541. * Puts a workshop on an active Pro subscription, as a paid checkout would.
  542. * Returns the plan's id so the spec can take it away again.
  543. */
  544. export async function giveProPlan(organizationId: string): Promise<string> {
  545. return withDb(async (db) => {
  546. const plan = await db.query<{ id: string }>(
  547. `insert into subscription_plans (id, name, price, "updatedAt")
  548. values (md5(random()::text || clock_timestamp()::text), 'E2E Pro', 0, now())
  549. returning id`
  550. )
  551. const planId = plan.rows[0].id
  552. await db.query(
  553. `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt")
  554. values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now())`,
  555. [organizationId, planId]
  556. )
  557. return planId
  558. })
  559. }
  560. /** Takes a subscription and its plan away again. */
  561. export async function removePlan(organizationId: string, planId: string): Promise<void> {
  562. await withDb(async (db) => {
  563. await db.query(`delete from subscriptions where "organizationId" = $1`, [organizationId])
  564. await db.query(`delete from subscription_plans where id = $1`, [planId])
  565. })
  566. }
  567. export interface PersonRecord {
  568. /** How many users hold the address: more than one is two people where there should be one. */
  569. users: number
  570. /** How each of them can sign in: `credential` for a password, `google`. */
  571. providers: string[]
  572. emailVerified: boolean
  573. }
  574. /** Who holds an address, and how they can sign in. */
  575. export async function personWithEmail(email: string): Promise<PersonRecord> {
  576. return withDb(async (db) => {
  577. const users = await db.query<{ id: string; emailVerified: boolean }>(
  578. `select id, "emailVerified" from users where lower(email) = lower($1)`,
  579. [email]
  580. )
  581. const providers = await db.query<{ providerId: string }>(
  582. `select a."providerId" from accounts a join users u on u.id = a."userId"
  583. where lower(u.email) = lower($1) order by a."providerId"`,
  584. [email]
  585. )
  586. return {
  587. users: users.rows.length,
  588. providers: providers.rows.map((row) => row.providerId),
  589. emailVerified: users.rows.some((row) => row.emailVerified),
  590. }
  591. })
  592. }
  593. /** Every vehicle row a workshop holds. */
  594. export async function vehicleRows(organizationId: string): Promise<number> {
  595. return withDb(async (db) => {
  596. const result = await db.query<{ n: number }>(
  597. `select count(*)::int as n from vehicles where "organizationId" = $1`,
  598. [organizationId]
  599. )
  600. return result.rows[0]?.n ?? 0
  601. })
  602. }
  603. /** One of a workshop's settings as stored, or null when it was never saved. */
  604. export async function workshopSetting(organizationId: string, key: string): Promise<string | null> {
  605. return withDb(async (db) => {
  606. const result = await db.query<{ value: string }>(
  607. `select value from app_settings where "organizationId" = $1 and key = $2`,
  608. [organizationId, key]
  609. )
  610. return result.rows[0]?.value ?? null
  611. })
  612. }
  613. /**
  614. * A vehicle registry connected to a workshop, active, the way the header's
  615. * plate lookup looks for one. No keys: nothing is looked up, only offered.
  616. */
  617. export async function connectRegistry(
  618. organizationId: string,
  619. userId: string,
  620. connectorId: string
  621. ): Promise<void> {
  622. await withDb((db) =>
  623. db.query(
  624. `insert into integration_connections
  625. (id, "organizationId", "connectorId", status, "createdById", "updatedAt")
  626. values ($1, $2, $3, 'active', $4, now())`,
  627. [`e2e-${connectorId}-${Date.now()}`, organizationId, connectorId, userId]
  628. )
  629. )
  630. }
  631. export async function disconnectRegistry(
  632. organizationId: string,
  633. connectorId: string
  634. ): Promise<void> {
  635. await withDb((db) =>
  636. db.query(
  637. `delete from integration_connections where "organizationId" = $1 and "connectorId" = $2`,
  638. [organizationId, connectorId]
  639. )
  640. )
  641. }
  642. /** The id of a workshop's customer with exactly this name. */
  643. export async function customerIdNamed(organizationId: string, name: string): Promise<string> {
  644. return withDb(async (db) => {
  645. const result = await db.query<{ id: string }>(
  646. `select id from customers where "organizationId" = $1 and name = $2`,
  647. [organizationId, name]
  648. )
  649. const id = result.rows[0]?.id
  650. if (!id) throw new Error(`no customer named ${name}`)
  651. return id
  652. })
  653. }
  654. // ─── The security specs ──────────────────────────────────────────────────────
  655. /**
  656. * A custom role carrying every action on every subject the app knows, and no
  657. * admin standing. It is the sharpest test of "logged in is not allowed": a
  658. * member with this role passes every `requiredPermissions` check there is,
  659. * and the owner-only and admin-only actions have to refuse them anyway.
  660. */
  661. export async function createRoleWithEveryPermission(
  662. organizationId: string,
  663. name: string
  664. ): Promise<string> {
  665. const subjects = [
  666. 'dashboard',
  667. 'vehicles',
  668. 'customers',
  669. 'work_orders',
  670. 'quotes',
  671. 'services',
  672. 'billing',
  673. 'inventory',
  674. 'labor_presets',
  675. 'inspections',
  676. 'tire_hotel',
  677. 'reports',
  678. 'settings',
  679. 'work_board',
  680. 'ai_assistant',
  681. 'time_tracking',
  682. ]
  683. const actions = ['create', 'read', 'update', 'delete', 'manage']
  684. return withDb(async (db) => {
  685. const role = await db.query<{ id: string }>(
  686. `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
  687. values (gen_random_uuid()::text, $1, false, $2, now(), now())
  688. returning id`,
  689. [name, organizationId]
  690. )
  691. const roleId = role.rows[0].id
  692. for (const subject of subjects) {
  693. for (const action of actions) {
  694. await db.query(
  695. `insert into permissions (id, action, subject, "roleId")
  696. values (gen_random_uuid()::text, $1, $2, $3)`,
  697. [action, subject, roleId]
  698. )
  699. }
  700. }
  701. return roleId
  702. })
  703. }
  704. /** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
  705. export async function setMembership(
  706. email: string,
  707. organizationId: string,
  708. membership: { roleId: string | null; role: 'member' | 'admin' }
  709. ): Promise<void> {
  710. await withDb((db) =>
  711. db.query(
  712. `update organization_members m
  713. set "roleId" = $3, role = $4
  714. from users u
  715. where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
  716. [email, organizationId, membership.roleId, membership.role]
  717. )
  718. )
  719. }
  720. /** The credential in a pending invitation, or null when there is none for the address. */
  721. export async function invitationTokenFor(
  722. email: string,
  723. organizationId: string
  724. ): Promise<string | null> {
  725. return withDb(async (db) => {
  726. const result = await db.query<{ token: string }>(
  727. `select token from team_invitations
  728. where email = $1 and "organizationId" = $2 and status = 'pending'`,
  729. [email, organizationId]
  730. )
  731. return result.rows[0]?.token ?? null
  732. })
  733. }
  734. /** How much of the workshop there is, for a test that must find it all still there. */
  735. export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
  736. return withDb(async (db) => {
  737. const counts: Record<string, number> = {}
  738. for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
  739. const result = await db.query<{ n: string }>(
  740. `select count(*)::text as n from ${table} where "organizationId" = $1`,
  741. [organizationId]
  742. )
  743. counts[table] = Number(result.rows[0].n)
  744. }
  745. return counts
  746. })
  747. }
  748. /**
  749. * A file row written straight to the job, bypassing the schema that guards
  750. * the action: what a record carried before the guard existed, or what a
  751. * restore could bring in. The path resolver is the last line for these.
  752. */
  753. export async function insertServiceAttachment(row: {
  754. serviceRecordId: string
  755. fileName: string
  756. fileUrl: string
  757. fileType: string
  758. }): Promise<string> {
  759. return withDb(async (db) => {
  760. const result = await db.query<{ id: string }>(
  761. `insert into service_attachments
  762. (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
  763. values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
  764. returning id`,
  765. [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
  766. )
  767. return result.rows[0].id
  768. })
  769. }
  770. export async function deleteServiceAttachments(ids: string[]): Promise<void> {
  771. await withDb((db) =>
  772. db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
  773. )
  774. }
  775. /** How many file rows carry a name, on any job. */
  776. export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
  777. return withDb(async (db) => {
  778. const result = await db.query<{ n: string }>(
  779. `select count(*)::text as n from service_attachments where "fileName" = $1`,
  780. [fileName]
  781. )
  782. return Number(result.rows[0].n)
  783. })
  784. }
  785. /** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
  786. export async function insertConnection(row: {
  787. organizationId: string
  788. connectorId: string
  789. credentials: string
  790. settings: Record<string, unknown>
  791. createdById: string
  792. }): Promise<string> {
  793. return withDb(async (db) => {
  794. const result = await db.query<{ id: string }>(
  795. `insert into integration_connections
  796. (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
  797. values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
  798. returning id`,
  799. [
  800. row.organizationId,
  801. row.connectorId,
  802. row.credentials,
  803. JSON.stringify(row.settings),
  804. row.createdById,
  805. ]
  806. )
  807. return result.rows[0].id
  808. })
  809. }
  810. /** Inbound text messages with exactly this body, for a workshop. */
  811. export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
  812. return withDb(async (db) => {
  813. const result = await db.query<{ n: string }>(
  814. `select count(*)::text as n from sms_messages
  815. where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  816. [organizationId, body]
  817. )
  818. return Number(result.rows[0].n)
  819. })
  820. }
  821. export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
  822. await withDb((db) =>
  823. db.query(
  824. `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
  825. [organizationId, body]
  826. )
  827. )
  828. }
  829. // ─── Work order titles ───────────────────────────────────────────────────────
  830. /** What a job is called and numbered, straight from its row. */
  831. export async function serviceRecordNames(
  832. serviceRecordId: string
  833. ): Promise<{ title: string; invoiceNumber: string | null }> {
  834. return withDb(async (db) => {
  835. const result = await db.query<{ title: string; invoiceNumber: string | null }>(
  836. `select title, "invoiceNumber" from service_records where id = $1`,
  837. [serviceRecordId]
  838. )
  839. const row = result.rows[0]
  840. if (!row) throw new Error(`no work order ${serviceRecordId}`)
  841. return row
  842. })
  843. }
  844. /** The words a title template can print about one vehicle and its owner. */
  845. export async function vehicleFacts(vehicleId: string): Promise<{
  846. licensePlate: string | null
  847. make: string
  848. model: string
  849. year: number
  850. vin: string | null
  851. customerName: string | null
  852. }> {
  853. return withDb(async (db) => {
  854. const result = await db.query<{
  855. licensePlate: string | null
  856. make: string
  857. model: string
  858. year: number
  859. vin: string | null
  860. customerName: string | null
  861. }>(
  862. `select v."licensePlate", v.make, v.model, v.year, v.vin, c.name as "customerName"
  863. from vehicles v
  864. left join customers c on c.id = v."customerId"
  865. where v.id = $1`,
  866. [vehicleId]
  867. )
  868. const row = result.rows[0]
  869. if (!row) throw new Error(`no vehicle ${vehicleId}`)
  870. return row
  871. })
  872. }
  873. /** Removes a workshop setting so the app falls back to its default for it. */
  874. export async function forgetWorkshopSetting(organizationId: string, key: string): Promise<void> {
  875. await withDb((db) =>
  876. db.query(`delete from app_settings where "organizationId" = $1 and key = $2`, [
  877. organizationId,
  878. key,
  879. ])
  880. )
  881. }