1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293# Database Schema
Equalify uses PostgreSQL as its primary database, accessed via direct queries (serverless-postgres) and GraphQL (Hasura).
## Core Tables
### audits
Stores audit configurations and metadata.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner's user ID (FK) |
| `name` | TEXT | Audit display name |
| `interval` | TEXT | Scan frequency (manual, daily, weekly, etc.) |
| `scheduled_at` | TIMESTAMP | Next scheduled scan time |
| `status` | TEXT | Current status (draft, new, processing, complete, failed) |
| `payload` | JSONB | Full audit configuration |
| `response` | JSONB | Latest scan response |
| `email_notifications` | TEXT | Email alert setting |
| `processed_at` | TIMESTAMP | When the audit was last processed |
| `remote_csv_url` | TEXT | Source URL for remote CSV URL sync |
| `remote_csv_error` | TEXT | Last error from remote CSV sync |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### urls
URLs associated with audits for scanning.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Owner's user ID (FK) |
| `audit_id` | UUID | Parent audit (FK) |
| `audit_ids` | JSONB | Additional audits this URL belongs to (array, defaults to `[]`) |
| `url` | TEXT | Full URL to scan |
| `type` | TEXT | Content type (html, pdf) |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### scans
Individual scan runs for audits.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `audit_id` | UUID | Parent audit (FK) |
| `status` | TEXT | Scan status (processing, complete, failed) |
| `percentage` | NUMERIC | Progress percentage (0-100) |
| `pages` | JSONB | Array of pages to scan |
| `processed_pages` | JSONB | Array of completed page IDs |
| `errors` | JSONB | Array of scan errors |
| `blocker_count` | INTEGER | Denormalized count of blockers found |
| `equalified_count` | INTEGER | Denormalized count of resolved blockers |
| `created_at` | TIMESTAMP | Scan start time |
| `updated_at` | TIMESTAMP | Last update timestamp |
### blockers
Individual accessibility issues found during scans.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `audit_id` | UUID | Parent audit (FK) |
| `scan_id` | UUID | Parent scan (FK) |
| `url_id` | UUID | URL where found (FK) |
| `short_id` | TEXT | Short, per-audit-unique identifier |
| `content` | TEXT | HTML snippet or context |
| `content_normalized` | TEXT | Normalized HTML used for hashing |
| `content_hash_id` | UUID | Hash for deduplication |
| `targets` | JSONB | CSS/DOM target selectors |
| `url_text` | TEXT | Denormalized URL snapshot (fallback if the `urls` row is deleted) |
| `equalified` | BOOLEAN | Marked as resolved |
| `created_at` | TIMESTAMP | Discovery timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### messages
Accessibility rule definitions.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `content` | TEXT | Rule/message text |
| `category` | TEXT | Message category |
### blocker_messages
Junction table linking blockers to messages.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `blocker_id` | UUID | Blocker reference (FK) |
| `message_id` | UUID | Message reference (FK) |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### tags
WCAG and other accessibility tags.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `content` | TEXT | Tag name (e.g., "wcag2aa") |
### message_tags
Junction table for message-tag relationships.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `message_id` | UUID | Message reference (FK) |
| `tag_id` | UUID | Tag reference (FK) |
| `created_at` | TIMESTAMP | Creation timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
### ignored_blockers
Blockers marked as ignored/resolved.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `audit_id` | UUID | Audit reference (FK) |
| `blocker_id` | UUID | Blocker reference (FK) |
| `content_hash_id` | UUID | Hash reference, used to match ignored blockers across scans |
| `created_at` | TIMESTAMP | When ignored |
| `updated_at` | TIMESTAMP | Last update timestamp |
### users
User accounts and profiles.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key (matches Cognito sub) |
| `email` | TEXT | User email address |
| `name` | TEXT | Display name |
| `type` | TEXT | User type (defaults to `member`; also `admin`) |
| `analytics` | JSONB | Analytics/tracking data |
| `apikey` | UUID | API key for programmatic access |
| `created_at` | TIMESTAMP | Account creation |
| `updated_at` | TIMESTAMP | Last update timestamp |
### invites
Pending invitations to join as a user.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Inviting user (FK) |
| `name` | TEXT | Invitee display name |
| `email` | TEXT | Invitee email address |
| `type` | TEXT | Invited user type |
| `expires_on` | TIMESTAMP | Invitation expiration |
### access_requests
Requests for access under SSO, reviewed by an admin.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `email` | TEXT | Requesting user's email |
| `status` | TEXT | Request status (pending, approved, denied) |
| `created_at` | TIMESTAMP | Request timestamp |
### blocker_llm_summaries
AI-generated summaries and flags for blockers.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `blocker_id` | UUID | Blocker reference (FK) |
| `summary` | TEXT | AI-generated summary text |
| `created_at` | TIMESTAMP | Creation timestamp |
### options
Key/value configuration store.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `key` | TEXT | Option name |
| `value` | JSONB | Option value |
### logs
Activity audit trail.
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID | Acting user (FK) |
| `audit_id` | UUID | Related audit (FK, nullable) |
| `message` | TEXT | Log message |
| `data` | JSONB | Structured log details |
| `created_at` | TIMESTAMP | Action timestamp |
| `updated_at` | TIMESTAMP | Last update timestamp |
User accounts are standalone, and `invites` is how additional users get added.
## Relationships
```
users
โโโ audits (many)
โโโ urls (many; also tracked via urls.audit_ids for multi-audit membership)
โโโ scans (many)
โ โโโ blockers (many, also FK'd directly to audits and urls)
โโโ blockers (many, direct FK)
โโโ blocker_messages (many) โโฌโโ messages (shared/deduplicated across audits)
โ โโโ message_tags (many) โโ tags (shared)
โโโ ignored_blockers (via content_hash_id)
```
`messages` and `tags` are deduplicated by content hash and shared across audits/scans โ a message or tag is not owned exclusively by one blocker, so the relationship is many-to-many via the `blocker_messages`/`message_tags` junction tables, not a strict one-way hierarchy.
## Content Hashing
Blockers, messages, and tags all use content hashing for deduplication, via a shared `hashStringToUuid` helper (double SHA-256, truncated to a UUID-shaped hex string):
```typescript
const contentNormalized = normalizeHtmlWithVdom(blocker.node);
const contentHashId = hashStringToUuid(contentNormalized);
const messageId = hashStringToUuid(blocker.description + blocker.test);
const tagId = hashStringToUuid(tag);
```
This allows:
- Tracking blocker persistence across scans (same normalized node content โ same `content_hash_id`)
- Identifying when blockers are resolved
- Deduplicating shared `messages` and `tags` rows across audits and scans
## GraphQL Access (Hasura)
Hasura provides GraphQL access with row-level security:
```graphql
query GetAuditBlockers($audit_id: uuid!) {
audits_by_pk(id: $audit_id) {
scans(order_by: {created_at: desc}, limit: 1) {
blockers {
id
content
url_id
blocker_messages {
message {
content
category
message_tags {
tag {
content
}
}
}
}
}
}
}
}
```
## Row-Level Security
Hasura defines two roles, `anonymous` and `user` (no `admin` role exists in Hasura). JWT claims carry the role and user ID:
```json
{
"x-hasura-allowed-roles": ["user"],
"x-hasura-default-role": "user",
"x-hasura-user-id": "user-uuid",
"x-hasura-org-id": "org-uuid"
}
```
Row-level ownership is **not** broadly enforced by Hasura permission filters โ most tracked tables (including `audits`) have an open `{}` select filter for the `user` role. The one exception is `urls`, where the update permission filters on `user_id`; its delete permission is open. Ownership checks are instead primarily enforced at the Lambda layer, where handlers filter directly on the authenticated user's ID (`event.claims.sub`) in SQL queries. Admin-only endpoints (e.g. `getSystemStats`, `reviewAccessRequest`) are likewise enforced in the Lambda handler based on `users.type`, not via a Hasura role.
## Atomic Operations
Scan progress uses atomic PostgreSQL operations to prevent race conditions:
```sql
UPDATE "scans"
SET
"processed_pages" = CASE
WHEN NOT (COALESCE("processed_pages", '[]'::jsonb) @> $1::jsonb)
THEN COALESCE("processed_pages", '[]'::jsonb) || $1::jsonb
ELSE "processed_pages"
END
WHERE "id" = $2
RETURNING "pages", "processed_pages"
```
---
*For API usage examples, see the Backend API Reference.*