db.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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. }