Lalabase
Tickets, Doku, Zeiterfassung und Kundengespräche an einem Ort, durchsuchbar per KI. Für Softwarefirmen mit Kundenprojekten. Open Source: auf deinem eigenen Server oder in unserer Cloud in Deutschland.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证maintenance assistance for student housing
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
A Next.js PWA for student tenants to report maintenance issues to their landlady.
Built for Eastwinds Property Group: roughly 80 student tenants across several
managed properties in Birmingham.
Live URL: https://appliance-helper-self.vercel.app
Auto-deploys from: main branch on GitHub (roshanbahia42/appliance-helper)
Admin dashboard: https://appliance-helper-self.vercel.app/admin/login
Set in Vercel → Settings → Environment Variables (and locally in .env.local):查看完整 README ↗A Next.js PWA for student tenants to report maintenance issues to their landlady. Built for Eastwinds Property Group: roughly 80 student tenants across several managed properties in Birmingham.
Live URL: https://appliance-helper-self.vercel.app
Auto-deploys from: main branch on GitHub (roshanbahia42/appliance-helper)
Admin dashboard: https://appliance-helper-self.vercel.app/admin/login
| Layer | Service |
|---|---|
| Framework | Next.js (App Router) with Geist font |
| Database | Supabase (tickets table) |
| File storage | Supabase Storage (ticket-media bucket, public) |
| Resend (still the sandbox sender until the domain is set up) | |
| Address lookup | Google Places API (New) — server-side proxy, restricted to Birmingham (15km radius) |
| Styling | Tailwind CSS v4, Lucide icons |
| Auth | Supabase Auth (admin only) |
| Hosting | Vercel (appliance-helper-self.vercel.app) |
Set in Vercel → Settings → Environment Variables (and locally in .env.local):
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
RESEND_API_KEY=
GOOGLE_PLACES_API_KEY=
LANDLORD_EMAIL= # currently Roshan's email — change to landlady's before go-live
APP_URL= # the live site address, used to build email links
RESEND_FROM= # e.g. "Maintenance <[email protected]>" — see below
RESEND_REPLY_TO= # the landlady's real inbox — unmatched inbound email is forwarded here
REPLY_DOMAIN= # the receiving domain for tenant replies — see Conversations
INBOUND_WEBHOOK_SECRET= # whsec_... from the Resend webhook, verifies inbound posts
Both jobs below are DNS changes on the landlady's existing domain, so do them together in one request to whoever manages it. Nothing here is urgent to a tenant, but until the email half is done students receive no confirmation emails at all — the fallback sender only delivers to addresses verified in the Resend account.
The app and the email sending must use different subdomains. A CNAME record has to be the only record at its name; the app needs a CNAME, and Resend needs MX and TXT records. Put both at the same subdomain and one will break.
| Purpose | Subdomain | Records |
|---|---|---|
| The app students visit | maintenance.example.co.uk | one CNAME, from Vercel |
| Sending email | send.example.co.uk | MX + TXT (or CNAMEs), from Resend |
Sender then reads [email protected]. Slightly redundant, but only
the domain registers with anyone reading it.
Every record then published, so restoring after an accident does not mean hunting through three dashboards and retyping a 220 character key. All of it is public by design: DNS records are readable by anyone, and a DKIM public key is public by definition. The private key lives at Resend and is not here.
Re-take it with dig against any public resolver if records change.
NAME TYPE VALUE
eastwindspropertygroup.co.uk A 149.255.56.43
www.eastwindspropertygroup.co.uk CNAME eastwindspropertygroup.co.uk.
eastwindspropertygroup.co.uk MX 10 mail.eastwindspropertygroup.co.uk.
mail.eastwindspropertygroup.co.uk A 149.255.56.43
eastwindspropertygroup.co.uk TXT v=spf1 +a +mx +a:dedi-133831.dedicloud.co.uk -all
_dmarc.eastwindspropertygroup.co.uk TXT v=DMARC1; p=quarantine; adkim=s; aspf=s
maintenance.eastwindspropertygroup.co.uk CNAME 9d45b0012d4fbd77.vercel-dns-017.com.
send.eastwindspropertygroup.co.uk NS ns1.send... / ns2.send...
send.eastwindspropertygroup.co.uk MX 10 inbound-smtp.eu-west-1.amazonaws.com.
rsend.send.eastwindspropertygroup.co.uk CNAME rsend-euw1.forge.rmta.net.
send.send.eastwindspropertygroup.co.uk CNAME send.forge.rmta.net.
resend._domainkey.send.eastwindspropertygroup.co.uk TXT
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNGE2gHJKUOvTX+DveTQPcHuoZfll4oZz7La628iML7LomIYomD5OcSYp4ZWH/nOomhFM8foudnFTOrmc9xYZQWN7o5ZJuVHswdF+b3Jwf+7a8GooZPx5qSLWRDtKOjGvKDl07cubTL0fwD9YskCwL9ekTveTDIj6+8JT/9oRW6QIDAQAB
The first four rows and the SPF are the landlady's website and office email. They have nothing to do with this project and must not be touched.
send. is its own DNS zone. Records go in it, not the main zoneRead this before asking anyone to change DNS under send., because getting it
wrong looks exactly like the record never being added.
During setup the host created send.eastwindspropertygroup.co.uk through the
control panel's Subdomains feature, which made it a separately delegated
zone rather than an ordinary name inside the main zone. The main zone now
holds nothing for it but a signpost:
send.eastwindspropertygroup.co.uk NS ns1.send.eastwindspropertygroup.co.uk
NS ns2.send.eastwindspropertygroup.co.uk
Two consequences, both of which cost a week the first time:
send. name in the main zone is ignored. Resolvers
follow the delegation and only read the send. zone. The record resolves as
absent, with no error anywhere to explain why..send.eastwindspropertygroup.co.uk to whatever is typed. Entering
rsend.send, which is right for the main zone, produces
rsend.send.send.eastwindspropertygroup.co.uk.So when requesting a change, give fully qualified names and say which zone
they belong in. resend._domainkey.send.eastwindspropertygroup.co.uk, not
resend._domainkey.send.
Check which situation you are in before assuming anything:
dig NS send.eastwindspropertygroup.co.uk +short
# ns1.send... / ns2.send... -> still delegated, records go in the send. zone
# (nothing, or the parent's own nameservers) -> ordinary name, main zone
Both nameservers point at the same server as the parent's, so the split buys
nothing. Collapsing send. back into the main zone would remove this trap for
good: delete the separate zone, then re-add its records (DKIM TXT, the two
CNAMEs, and the MX) in the main zone. Worth doing during a quiet period, never
while something is broken.
Vercel → project → Settings → Domains → Add → enter the app subdomain. It shows one CNAME record.
Resend → Domains → Add Domain → enter the sending subdomain. It shows three records. Note the Type column rather than the labels: Resend files the MX record under "SPF", so "one DKIM and two SPF" is the expected three.
Optionally add DMARC yourself, scoped to the sending subdomain so it cannot
affect her existing mail: TXT at _dmarc.send, value
v=DMARC1; p=none; rua=mailto:[email protected].
Screenshot both sets rather than retyping. A single dropped character in a DKIM key fails silently. Include:
send.example.co.uk into send.example.co.uk.example.co.uk.Copy her in. An unknown person asking for DNS changes should make a good web developer suspicious.
Once both verify:
RESEND_FROM to Eastwinds Maintenance <[email protected]>APP_URL to the new app address. This builds the "View your
request" link in every confirmation email, so missing it leaves students with
dead vercel.app linksSHOW_DELIVERY_WARNINGS to false in TicketTable.tsxEmail failures are logged to the Vercel function logs (Email to tenant failed for MT-…) and never fail a submission, since the ticket is already saved.
src/
app/
layout.tsx # root layout: Geist font, PWA meta tags
page.tsx # entire multi-step student form (state machine)
Brand.tsx # arch mark + wordmark lockup
SiteHeader.tsx # navy header used on every screen
icon.svg # favicon
globals.css # Tailwind base + global button cursor
manifest.ts # student PWA manifest (start_url: "/")
api/
submit/route.ts # POST: saves ticket to DB, sends emails
places/route.ts # GET: Google Places autocomplete proxy (Birmingham only)
upload-url/route.ts # POST: generates signed Supabase upload URL (bypasses RLS)
job-batch/route.ts # POST: creates a handyman job sheet from selected tickets
message-tenants/route.ts # POST: emails a whole property, ticket-scoped or announcement
inbound-email/route.ts # POST: Resend webhook, threads student replies onto tickets
find-ticket/route.ts # POST: emails a student links to their tickets
export/route.ts # GET: CSV of every ticket and its messages
thread/[token]/reply/route.ts # POST: student reply from the public thread page
tickets/bulk/route.ts # POST: bulk bin / restore / purge / resolve / reopen
tickets/[reference]/
resolved/route.ts # POST: marks resolved (media kept), notifies the student
escalate/route.ts # POST: marks escalated (status only, no email)
reopen/route.ts # POST: sets status back to open
reply/route.ts # POST: the landlady's reply on a ticket's thread
read/route.ts # POST: marks a ticket's inbound messages read
delete/route.ts # POST: moves to bin (soft delete)
restore/route.ts # POST: restores from bin
purge/route.ts # POST: permanent delete — row + media files
notes/route.ts # POST: saves the landlady's note for the handyman
confirmation/[reference]/
page.tsx # confirmation page (server component)
CloseTicketButton.tsx # client component — subtle self-resolve link
job/[token]/
page.tsx # public handyman job sheet — no auth, unguessable token
t/[token]/
page.tsx # public ticket thread for the student, same trust model
ThreadReplyBox.tsx # client component: the student's reply box
find-ticket/
page.tsx # emails a student their ticket links
admin/
layout.tsx # admin layout — links to admin PWA manifest
login/page.tsx # landlady login (Supabase Auth)
dashboard/
page.tsx # auth-gated dashboard + automatic bin purge
DashboardHeader.tsx # header with ticket count + logout
TicketTable.tsx # state, filtering, list views, selection bar
TicketDetail.tsx # one ticket: details, actions, notes, attachments
Thread.tsx # the conversation view + reply composer
AttachmentLightbox.tsx # full-screen viewer with download fallback
MessageTenants.tsx # compose box for messaging a whole house
lib/
categories.ts # source of truth: all categories, subcategories, tips
# also contains CONTACTS object with landlady/landlord phone numbers
tickets.ts # Ticket type + pure logic: age, bin maths, sort/filter
# config, handyman message formatters
messages.ts # TicketMessage type + pure logic: matching, parsing, unread
stripReply.ts # server-only quote stripping for inbound email
notify.ts # the resolved-notification email, shared by both resolve paths
categoryIcons.ts # category id -> Lucide icon
media.ts # browser-side image compression + HEIC to JPEG
storage.ts # shared helpers for deleting ticket media
middleware.ts # refreshes the Supabase session, guards /admin/dashboard
utils/
supabase/
admin.ts # service-role client (server only)
requireAdmin.ts # 401 guard for admin-only API routes
server.ts # session-aware client (server)
client.ts # browser client
public/
brand/ # source SVGs for the mark and wordmark
icon-512.png / icon-192.png # PWA icons, rendered from brand/
apple-icon.png # iOS home screen
admin-manifest.webmanifest # admin PWA manifest (start_url: "/admin/login", scope: "/admin/")
icon-192.png # PWA icon (placeholder — swap for real logo)
icon-512.png # PWA icon (placeholder)
tickets table columns| Column | Type | Notes |
|---|---|---|
| id | uuid (PK) | |
| reference_number | text | |
| tenant_name | text | |
| tenant_room | text | Room number — required at submission |
| tenant_email | text | |
| tenant_phone | text | Required at submission |
| property_address | text | |
| category | text | |
| description | text | Required at submission |
| status | text | open / escalated / resolved |
| media_urls | text[] (nullable) | Public Supabase Storage URLs — min 1 required at submission |
| sent_to_handyman_at | timestamptz (nullable) | Set when included in a job sheet |
| deleted_at | timestamptz (nullable) | Set when moved to the bin; purged after 30 days |
| admin_notes | text (nullable) | Landlady's private notes — never shown to tenant or handyman |
| created_at | timestamptz | |
| updated_at | timestamptz |
Safe to run repeatedly — everything is IF NOT EXISTS, so a partially-migrated
database is fine. (A plain CREATE TABLE on an existing table errors and
silently skips every statement after it, which is easy to miss.)
CREATE TABLE IF NOT EXISTS job_batches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token text UNIQUE NOT NULL,
reference_numbers text[] NOT NULL,
created_at timestamptz DEFAULT now()
);
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS tenant_room text;
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS sent_to_handyman_at timestamptz;
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS admin_notes text;
-- Null until the landlady opens the ticket. Without it a brand new report
-- looked identical to one she had already dealt with, since the unread badge
-- only ever counted student replies.
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS seen_at timestamptz;
-- Conversation threads. One row per message, either direction.
CREATE TABLE IF NOT EXISTS ticket_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id uuid NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
sender_name text,
sender_email text,
body text NOT NULL,
attachments jsonb NOT NULL DEFAULT '[]'::jsonb,
provider_message_id text UNIQUE,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ticket_messages_thread_idx
ON ticket_messages (ticket_id, created_at);
-- Unguessable token used in both the reply address and the student thread URL.
-- 12 hex characters, around 48 bits: short enough that the reply address and
-- thread link stay readable, far more than enough that nobody guesses one.
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS public_token text
DEFAULT substr(replace(gen_random_uuid()::text, '-', ''), 1, 12);
ALTER TABLE tickets ALTER COLUMN public_token
SET DEFAULT substr(replace(gen_random_uuid()::text, '-', ''), 1, 12);
UPDATE tickets SET public_token = substr(replace(gen_random_uuid()::text, '-', ''), 1, 12)
WHERE public_token IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS tickets_public_token_idx
ON tickets (public_token);
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE job_batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE ticket_messages ENABLE ROW LEVEL SECURITY;
GRANT ALL ON TABLE ticket_messages TO service_role;
If a properties table exists, enable RLS on it too:
ALTER TABLE properties ENABLE ROW LEVEL SECURITY;
The middleware only guards /admin/dashboard, which stops someone loading the
page but does nothing for the routes behind it. requireAdmin() gates every
admin route: bulk, job-batch, message-tenants, notes, delete, purge, restore,
escalate and reopen. Without it, anyone who knew the URL could POST to
/api/tickets/bulk and permanently purge tickets.
/resolved is deliberately left open, because the tenant's own close-ticket link
on the confirmation page calls it. /submit, /places and /upload-url are the
public student flow, and /find-ticket and /thread/[token]/reply are public by
design: the first never reveals anything, the second is keyed by the unguessable
token. /inbound-email is protected by the Svix signature instead of a session,
because Resend is the caller.
src/lib/rateLimit.ts, applied per IP over a 10 minute window:
| Route | Limit | Protecting |
|---|---|---|
/api/submit | 10 | the Resend daily send cap |
/api/find-ticket | 5 | the same cap |
/api/upload-url | 40 | storage quota and the public bucket |
/api/places | 120 | Google Places billing |
The real target is the daily email cap. /submit and /find-ticket both
send mail without a session, so a few dozen scripted calls could exhaust the
day's quota, leaving real students with no confirmation and the landlady with
no urgent alerts. That failure looks like the system being broken rather than
like an attack, which is what makes it worth preventing.
Short windows rather than daily caps, and generous numbers, because students share IP addresses: a house is behind one router and university wifi NATs hundreds of people. A script makes hundreds of requests, so even a loose limit stops it while never touching someone reporting three faults in one sitting.
State is in-process, so it resets on cold start and is not shared between serverless instances. Partial rather than airtight: it stops a script hammering from one address, which is the realistic threat, but not a distributed attempt. A shared store such as Upstash is the upgrade if that ever matters.
Every caller degrades sensibly on a 429: the form shows the message, uploads name the file that failed, and the address lookup falls back to manual entry.
MT-2026-27581 is five random digits: 90,000 possibilities, and generated with
Math.random(). It exists so a student can quote their ticket on the phone, so
it has to stay short and readable. That makes it unusable as a credential.
Nothing public may be keyed on a reference number. Every public page and
route is keyed on tickets.public_token instead, which is 12 hex characters,
around 48 bits, and never displayed as an identifier.
This was not always true. /confirmation/[reference] was public and keyed on
the reference while selecting *, so walking the range would have returned
every tenant's email, address, room number and photographs. /resolved was
public and keyed on the reference too, so the same walk could have closed every
ticket and emailed all of them about it. Both are fixed; the shape of the
mistake is worth remembering.
The split to preserve:
| Key | Auth | |
|---|---|---|
/confirmation/[token], /t/[token], /job/[token] | token | none, token is the key |
/api/thread/[token]/reply, /api/thread/[token]/resolve | token | none, token is the key |
/api/tickets/[reference]/* | reference | requireAdmin() |
Reference-keyed routes are for the dashboard and must stay behind a session. Tenant-facing equivalents get their own token-keyed route rather than opening up an admin one.
The anon key is public — it ships in the browser bundle — so without RLS anyone
can read and write the database directly using it. Every app query runs
server-side with the service role key, which bypasses RLS entirely, so enabling
RLS with zero policies locks out the anon key while the server keeps full
access. The browser only uses Supabase for signInWithPassword and signOut,
which hit the auth API rather than any table, so nothing breaks.
If a browser-side table query is ever added it will silently return nothing until a policy is written for it.
ticket-media storage bucketPhotos are downscaled to 1600px and re-encoded as JPEG in the browser before
upload (src/lib/media.ts), typically a 10x reduction while staying sharp enough
to diagnose a fault. This keeps the project inside the 1GB free storage tier —
roughly 1,400 tickets rather than 170.
It also converts HEIC to JPEG, so iPhone photos display in the dashboard and job sheet instead of falling back to a download link. If the browser can't decode the format (Chrome and Firefox can't read HEIC), the original file uploads unchanged, so compression failing never blocks a submission — the HEIC download fallbacks in the UI remain as a safety net.
Photos arriving by email are not compressed. They skip the browser entirely, so they land at around 3 MB rather than 300 KB. They were resized server-side with sharp, which was removed: sharp resolves its native library through a dynamic require that Vercel's file tracing cannot follow, and it failed in production twice, once taking the whole inbound webhook down with it.
The saving was roughly 150 MB a year, set against an annual clear-out that already keeps the project inside the free tier, and a 100 GB paid tier at about $25 a month if it ever does not. Not a good trade for a fragile native dependency sitting in the path of every inbound student message.
If storage ever does get tight, the lever is the video cap, not photo quality: one 25 MB video is worth eighty compressed photos.
Video can't be compressed browser-side, which is why the cap is lower: a single 25MB clip costs as much storage as ~35 compressed photos.
Step 1 — Category grid (tap to select)
└─ has subcategories?
├─ Yes, non-emergency → Step 2 — Subcategory
│ └─ Step 3 — Property (progress bar: 3)
│ └─ Step 4 — Troubleshooting tips + YouTube suggestion (progress bar: 4)
│ ├─ "Problem solved!" → success screen (no ticket created)
│ └─ "I still need help →" → Step 5
├─ Yes, emergency/urgent → Step 2 — Subcategory
│ └─ Step 3 — Tips with emergency steps + contact numbers (progress bar: 3)
│ └─ Step 4 — Property (progress bar: 4)
│ └─ Step 5
└─ No subcategories (Other / Not Sure) → Step 3 — Property → Step 5
Step 5 — Tenant details (all fields required):
Full Name*, Room No.*, Email*, Phone*, Description*, Photos or videos* (min 1)
└─ Submit → POST /api/submit → redirect to /confirmation/[reference]
escalated and immediately email the landladyopen — landlady checks dashboardlocationRestrictionapi/places only returns buildings (street_address, premise, subpremise),
so a tenant has to type "12 Tiverton" rather than "Tiverton Road".
Including route and postal_code was tried so that street and postcode
searches returned something, and reverted. Google autocomplete matches text, it
cannot enumerate — there is no query meaning "list every house on this street",
so selecting a street simply returned the same street. It was a dead end, and it
also risked someone submitting "Tiverton Road, Birmingham" with no house number.
The real issue was discoverability, not capability: nothing told tenants to lead with their number, so typing a street name looked broken. The helper text and the no-results message now say so explicitly.
If postcode → house list is genuinely wanted, it needs a UK PAF-backed API
(getAddress.io, Ideal Postcodes, Royal Mail PAF direct). Those enumerate properly:
type B29 7QW, get all fourteen houses. A few pounds a month at this volume.
Google cannot do it at any price.
/confirmation/[reference])Shows:
| Status | Meaning | How it's set |
|---|---|---|
open | Submitted, awaiting attention | Default on submission |
escalated | Priority — needs urgent attention | Emergency at submission; admin can escalate |
resolved | Fixed / closed | Admin or tenant marks resolved |
Resolved tickets keep everything — the landlady needs a written record of what was reported. Media is removed only when a ticket is purged from the bin.
Status answers how urgent is this. The ✓ answers has it been passed on. A ticket carries both independently:
| Not sent | Sent | |
|---|---|---|
| open | New, waiting for the next handyman round | With the handyman, routine |
| escalated | Needs action now — can't wait for the round | Urgent, handyman has it |
Escalate must not be repurposed to mean "dealt with" — the ✓ already records
that, emergency submissions set escalated automatically, and overloading it
would make the red flag meaningless exactly when it matters most.
/admin/login → /admin/dashboard)To create admin credentials: Supabase → Authentication → Users → Add user.
Mobile-first layout — designed for phone use:
Features:
STALE_AFTER_DAYS) — repairs must happen within a reasonable
time of being reportedmailto: / tel: linksHousemates sharing a boiler produce several tickets for one job. A related-reports panel was built for this and then removed: it fired on same-property-within-14-days regardless of category, so in a shared house it triggered on most tickets, and judging a duplicate needs the other ticket's photos and description, which a summary line can't provide.
The job sheet already groups by property, so duplicates arrive as adjacent lines in the WhatsApp message — visible at the moment they matter, with no UI. The cost of one slipping through is a line the handyman ignores; the cost of wrongly merging two real faults is one never getting fixed. Grouping is the better trade.
Deleting a ticket moves it to the bin rather than destroying it:
purgeExpiredBin in
admin/dashboard/page.tsxRecords are kept for the tenancy year, then cleared when tenants change over:
That leaves a 30-day grace period before they're purged automatically. To skip the wait, open the Bin tab, select all, and Delete permanently — that one asks for confirmation, since it can't be undone.
Bulk actions run as a single request (/api/tickets/bulk) rather than one call
per ticket, so clearing a few hundred at once is fine.
Ticking any checkbox raises a bar at the bottom of the screen. What it offers depends on which tab you're in:
| Tab | Actions |
|---|---|
| All / Open | 🗑 Bin · Urgent · Resolve · Send |
| Escalated | 🗑 Bin · Resolve · Send |
| Resolved | 🗑 Bin · Reopen · Send |
| Bin | Restore · Delete |
Buttons lay out as a 2-column grid on mobile so four fit without cramping.
Every action confirms first, including Restore — an accidental restore drops
tickets back among hundreds of others with no easy way to find them again. The
prompts and button colours live in BULK_CONFIRM in TicketTable.tsx.
Messaging one tenant happens in the ticket's conversation thread (see Conversations below). Message everyone at this property in the ticket detail starts the whole house on that same thread, with the ticket's details included in the email. The filter row also carries a whole-house button for announcements with no particular ticket in hand; replies to those go to the landlady's inbox, not a thread.
Property sends go through /api/message-tenants so the sender address, the
template and the one-copy-each rule can only behave one way. The tenant's email
is shown as plain text rather than a mailto: link, because handing off to the
device's mail client sends from whatever personal account it happens to use,
which is the problem the thread exists to solve.
Whole-house recipients are worked out server-side from live tickets at that address within the last year, so there is no tenant list to maintain: the annual clear-out doubles as list maintenance, because binning last year's tickets drops last year's tenants. The one-year window is a backstop for a clear-out that gets skipped.
Everyone receives their own copy. One email with the whole house in the To field would disclose every tenant's address to the others.
Tenants who have never reported anything won't be on the whole-house list. Asking each new intake to file one test ticket at the start of the year closes that gap.
Every ticket has a message thread. The landlady writes in the ticket's conversation box; the student gets it by email and just hits reply; the reply lands back on the thread. She gets no email for routine replies: the Needs reply tab and the blue "new" badges in the dashboard are her signal, which is the point: her inbox stays quiet and the written record stays complete.
How a reply finds its ticket, in priority order:
ticket-<public_token>@<REPLY_DOMAIN>. The token comes back in the To
field, or in received_for, and is matched against tickets.public_token.
The older ticket+ form is still accepted, so replies to email sent
before the change keep working.In-Reply-To/References matched against stored
provider_message_ids.MT-YYYY-NNNNN, which every outbound
subject carries.Anything that matches nothing, or matches a binned ticket, is forwarded to
RESEND_REPLY_TO, never dropped. Auto-replies (out-of-office and the like)
are detected by header and subject and discarded. Retries are safe: messages
dedupe on the provider's message id.
Replies to a resolved ticket reopen it, because "it's not actually fixed" is the most likely content. Resolving a ticket emails the student, and that email's reply-to is the tagged address, so their "no it isn't" comes straight back and reopens it.
Photo attachments on replies are kept, but recompressed server-side with sharp (1600px, JPEG 80) before storage. Emailed photos arrive ~10x larger than the browser-compressed ones, and this is the main storage control. Known video types are stored up to the same 25MB cap as the form; other file types are skipped because the bucket is public and an .html or .svg would execute script.
Setup (Resend side):
Emails → Receiving gives the account a <id>.resend.app test domain that
works with no DNS. REPLY_DOMAIN points there until cutover.
Webhooks → add the endpoint https://<app domain>/api/inbound-email with
the email.received event; the signing secret is INBOUND_WEBHOOK_SECRET.
At cutover: enable receiving on the existing send.<domain>, which is
already verified for sending, and send the one MX record it produces to
the web person. It must be the lowest priority MX on that name. Then set
REPLY_DOMAIN to send.<domain> and redeploy. Nothing else changes.
Receiving on the sending domain rather than a separate reply. subdomain
keeps it to a single MX record with no verification records, and means
the reply-to matches the From domain. send.<domain> has no MX of its
own: Resend's bounce handling lives at send.send.<domain>, a different
name, so the two do not collide. The app's own subdomain is the one place
this cannot go, because its CNAME must be alone at that name.
For the first weeks, Resend's own "forward to an address" switch is worth turning on as a belt-and-braces net: the landlady gets a copy of every inbound email even if the webhook misbehaves. Deliberately noisy; switch it off once trusted.
Quote stripping (removing the quoted email under a reply) is heuristic: the library handles Gmail and Outlook, but expect occasional stray quoted text from unusual clients. A failure falls back to keeping the full text, never to losing the message.
The student's side:
/t/<public_token> is a public thread page, same trust model as the job
sheet: the unguessable URL is the key. History, a reply box, the emergency
phone numbers always visible, and a note that replying to a resolved ticket
reopens it. It is both the chase-up route and the fallback when an emailed
reply can't be matched./find-ticket emails a student links to their tickets, for when the
confirmation email is long gone. The response never reveals whether an
address has tickets.Export: the Export CSV link in the dashboard filter row downloads every ticket with its messages, one row per ticket, one per message under it. Cells are escaped against spreadsheet formula injection.
The handyman visits roughly fortnightly, so the routine before a visit is:
Selecting tickets that have already gone out is fine: the send asks first and offers Send N new (skipping the duplicates) or Send all N (deliberate re-send, e.g. the handyman lost the message). It never re-sends silently.
After the visit, bulk Resolve whatever got done.
The landlady's existing workflow was writing jobs into her notes app grouped by property and sending one WhatsApp per property. The dashboard automates that:
Hi, 5 jobs:
1 Example Road
• Room 1 — Plumbing — Shower leaking
• Kitchen — Furniture — Coffee table leg
2 Example Road
• Room 6 — Furniture — Bed broken
Full details + photos: https://.../job/a1b2c3d4e5f6
The link opens a job sheet — a public mobile page grouping every issue by property with photos inline, urgent flags, and room numbers. This solves the attachment problem: one link instead of a wall of raw image URLs.
The handyman is deliberately not given tenant phone numbers or emails — access is arranged through the landlady.
Job sheets have no login. The 12-character random token is unguessable, and the page shows nothing beyond what the handyman needs to do the work.
Tickets included in a job sheet are marked with a green ✓ Sent so it's clear what has already been dispatched.
The landlady can install the admin dashboard as a home screen app:
https://appliance-helper-self.vercel.app/admin/login in SafariThe admin pages use a separate manifest (/admin-manifest.webmanifest) with start_url: "/admin/login" so the app opens correctly every time.
| Trigger | Recipient | Content |
|---|---|---|
| Any submission | Tenant | Reference, issue summary, link to confirmation page |
| Emergency submission | Landlady (LANDLORD_EMAIL) | Urgent red-header email with full tenant + issue details, the tenant's photos inline, and a link to the dashboard |
Sandbox limitation: Using [email protected]. Only delivers to verified addresses in Resend. Needs a real sending domain to reach any student email.
Files upload via signed URLs:
POST /api/upload-url with filename + content typemedia_urls[] on the ticketNo storage RLS policies needed. Progress bar tracks per-file upload progress.
All categories, subcategories, and troubleshooting tips live in src/lib/categories.ts. To add or edit:
idsubcategories[] — each entry has id, name, description, tips[], optionally isUrgent: trueEmergency contact numbers (landlady + landlord 2) are in the CONTACTS object at the top of the file. Update there if numbers change — they're referenced in all emergency tips automatically.
Categories with no subcategories skip steps 2 and 3.
MT-{year}-{5-digit-random} — e.g. MT-2026-47382
npm install
# create .env.local with the variables listed above
npm run dev
Type-check: npx tsc --noEmit
media_urls point at files that no longer exist.These came out of a full review and are design questions rather than bugs.
A student can't get back to their ticket. The reference is only ever shown
once and in an email that currently never arrives. Closing the tab loses it.
/confirmation/<reference> works for anyone who has the number, so a "check my
request" box on the home page would be enough.
Nothing sets expectations. The confirmation page says the landlady has been notified but never says roughly when to expect someone, so a tenant has no idea whether silence at day three is normal.
A photo is required for every report. Fine for a cracked tile, awkward for no hot water, an intermittent noise, or a smell — there is nothing to photograph, so the tenant either abandons the form or submits a meaningless picture. Consider requiring it only where it helps.
The landlady has no triage view. Counts and filters exist, but nothing says "these three need you today". Sorting by age gets close.
The job sheet is fire-and-forget. Once sent there's no signal back, so the only way to know whether work happened is to ask.
Done: domain setup (sending, app URL, receiving MX all verified), and
LANDLORD_EMAIL now points at the landlady's real address.
Blocked on someone else
send.<domain>
as self-spoofing and drops it, so emergency alerts do not arrive. Not a DNS
problem: the mail passes SPF and DKIM, and Resend reports it delivered,
meaning her server accepted it and then binned it with a rule. Needs an
exception scoped to DKIM-authenticated mail from that subdomain, rather
than a From-address allowlist, which would reopen the hole the protection
exists to close. Workaround if it stalls: point LANDLORD_EMAIL at an
address off her domain, which sidesteps the heuristic entirely.Yours to do
Conversations cutover. Move REPLY_DOMAIN from the Resend test domain
to send.<domain> and redeploy. Nothing else changes: every outbound email
already builds its reply-to from that variable, and falls back to
RESEND_REPLY_TO if it is unset. The test domain keeps receiving
throughout, so replies to mail already sent are never stranded.
Retest the student flow. Submit, confirmation page, close ticket, reply. The confirmation URL changed shape when it moved off the reference number, and that is the path every student walks.
Test with a university address, not just Gmail. Most students use one and they filter hardest. Four things: the confirmation lands in the inbox rather than spam, a reply from that address threads onto the ticket, the reply is not swallowed by the auto-reply filter (university systems are the likeliest to attach headers that trip it), and the thread link still works after Microsoft Safe Links rewrites it, which it does to every URL.
Phone testing, both sides. The dashboard is mobile-first and it is the only way she will use it.
Rotate API keys. Resend and Google Places. The Supabase keys are
deliberately left alone: the only exposure was an assistant reading
.env.local in one session, so the values went into a conversation
transcript rather than anywhere public, and rotating them logs the landlady
out and briefly takes the database offline. Worth doing on a quiet
afternoon, not before launch. If they are ever exposed more widely, the
service role key is the one that matters, because it bypasses RLS
completely.
Clear the test data, last. Rotating keys means testing afterwards, which
creates fresh tickets, so this genuinely goes at the end. Delete from
ticket_messages, tickets and job_batches, empty the ticket-media
storage bucket (deleting rows does not remove uploaded files), and set the
shorter token default:
ALTER TABLE tickets ALTER COLUMN public_token
SET DEFAULT substr(replace(gen_random_uuid()::text, '-', ''), 1, 12);
Optional, not blocking
v=spf1 +a +mx +a:dedi-133831... TXT on send, copied
down from the root during setup and wrong there.maintenance.<domain> lost its CNAME during DNS work and has been
unresolvable since. The app itself was never affected: it serves fine on the
Vercel address. These point at that address instead and must be moved back,
or they will quietly stay on a fallback URL that breaks if the project is ever
renamed.
| What | Temporarily | Back to |
|---|---|---|
APP_URL in Vercel | appliance-helper-self.vercel.app | https://maintenance.<domain> |
| Resend webhook URL | …vercel.app/api/inbound-email | https://maintenance.<domain>/api/inbound-email |
APP_URL matters most: every link in every email is built from it, so while it
points at a dead domain, confirmations, thread links and ticket-recovery emails
all lead nowhere. The webhook matters second: Resend cannot deliver inbound
replies to an address that does not resolve, so replies stop threading.
Neither needs code changes. APP_URL needs a redeploy; the webhook URL does
not, and its signing secret is unchanged.
Check the webhook has not been auto-disabled after a week of failures. Resend switches endpoints off after repeated errors, and retains undelivered events, so expect a burst of queued replies once it can reach the app again. The dedupe on message ID makes that safe.
escalated earn its place? — with the ✓ recording dispatch, its only
remaining job is "can't wait for the fortnightly round". If she wouldn't use
it, statuses collapse to open/resolved and emergency submissions need another
way to signal urgency.CSV export was on this list and is now built. It exports every ticket with its messages, escaped against spreadsheet formula injection. It preserves the written record only: photo links die with the tickets, so an export taken before a changeover is not a substitute for the photos themselves.
confirmation_failed flag on the
ticket would let her spot a tenant who never got confirmation.formatBatchText in
TicketTable.tsx. No infrastructure change.