Skip to content

Database schema - #52

Merged
jasonappah merged 32 commits into
devfrom
dbSchema
Oct 1, 2026
Merged

jasonappah merged 32 commits into
devfrom
dbSchema

Conversation

@jasonappah

@jasonappah jasonappah commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #28

Description

CleanShot 2026-10-01 at 4 31 05 PM

ERD generated by https://jurerotar.github.io/sqlite-erd/

This PR sets up the initial schema for tickets and projects. worked on this as a team on a Discord VC on 9/26/2026 then did final review with Tushar during lab on 10/1

SJAryan and others added 24 commits September 26, 2026 20:42
Registers the organization plugin on the server and its client
counterpart so org-scoped endpoints and actions are available.
Schema tables for it land in a follow-up commit.
The `Tickets` identifier was the only mixed-case table; the rest of
the schema is lowercase. Aligning it so future migrations and raw
SQL references are consistent.
Tables required by the better-auth organization plugin, plus the
`activeOrganizationId` column on `session` and the matching drizzle
relations. Membership role now lives on `member`, one per org.
Both are superseded: membership is now modelled by the `member`
table's per-organization role, and project membership is a relation
rather than a single foreign key. Neither column existed in any
applied migration, so this is schema-only.
Adds the columns the plugin requires: `role`, `banned`, `banReason`,
`banExpires` on `user` and `impersonatedBy` on `session`. `role` is
nullable and platform-wide (`user`/`admin`), distinct from the
per-organization role on `member`.
Adds a required `organizationId` foreign key with cascade delete and
an index, plus the matching drizzle relations on both sides.
Covers organization, member, invitation, project, and tickets. Insert
schemas for the auth-owned tables omit the columns Better Auth marks
`input: false` so admin/ban state, active organization, and
impersonation cannot be set via a validated body.
Adds the organization/member/invitation tables, the admin and active
organization columns, and the previously unmigrated project and
tickets tables. Entirely additive: no drops or renames, so it applies
cleanly to a database at the 0000 baseline.
The script only created a user, so the new organization tables went
unexercised. Splits seeding into guarded per-entity steps covering the
user, the organization, and the user's owner membership. Each step
checks for an existing row first because `member` has no unique index
on (organizationId, userId) and an upsert would duplicate on re-run.
…oles

Roles and permissions live in shared/access-control.ts so the server and
the client derive the same types. Extends the plugin's default
statements with `ticket` and `project` resources, keeps each default
role's existing grants, and enables dynamic access control so an
organization can define extra roles at runtime.

Passing `ac`/`roles` to organizationClient() is type-level only --
it is not serialized into requests -- but it must stay in sync with
the server options.
Stores the per-organization roles created at runtime as a JSON
permission map, with relations and zod schemas. The export name must
match the Better Auth model name because the Drizzle adapter resolves
tables by model, not by SQL table name.
…ence

Renames:
- `projectName` -> `name`, per the convention of not repeating the
  entity name in a column.
- `githubLink` -> `githubRepo`, storing GitHub's `full_name`
  (owner/repo) instead of a URL. Every REST endpoint is addressed by
  `/repos/{owner}/{repo}` and there is no REST lookup by repository id,
  so neither a URL nor a bare id can be used to call the API. The
  GraphQL node id is only resolvable via `node(id:)` and must itself be
  looked up by name first.

Drops `orgName`, which duplicated the partner's name now that a
partner is reachable by foreign key.

Merges `organizationId` into `projectPartnerId`. The former was a
foreign key and the latter a plain text column describing the same
thing, so a project could name two different partners. The merged
column keeps the domain name and is the single reference, with cascade
delete. The relation key on `projectRelations` becomes
`projectPartner`.

The `organization` table name and export are unchanged: the Better Auth
plugin resolves them by model name, and the Drizzle adapter looks up
`schema[model]`, so renaming would break every organization query.
The organization table represents a project partner, so the seed names
it that way. Table and export names are untouched, and a comment on the
access control `organization` resource records why that key has to keep
the plugin's name.
drizzle-kit 0.31.10 generated the data-copy step for the SQLite table
rebuild with the new column names on both sides:

  INSERT INTO __new_project("id", "name", "githubRepo", "projectPartnerId")
  SELECT "id", "name", "githubRepo", "projectPartnerId" FROM project

The SELECT reads from the pre-rename table, which has `projectName`
and `githubLink`, so this fails with 'no such column: name'. Corrects
the source side to the old names. The snapshot recorded the renames
correctly, so subsequent generates are unaffected.

Verified against a seeded row: projectName -> name, githubLink ->
githubRepo, projectPartnerId carried over, orgName and organizationId
dropped, foreign key on the merged column.
`projectId` was `text` against `project.id`, an integer primary key,
so the reference could not be joined and nothing enforced it. Changes the
column to `integer` with a real foreign key, adds an index for the
per-project ticket query, and declares the relation on both sides.

Cascade delete, matching organization -> project: a ticket has no
meaning without its project.

`tickets` is declared after `project` so the foreign key resolves in
declaration order, consistent with the rest of the file.
SQLite table rebuild. Unlike the rename migration, the data-copy step
here is correct as generated: `projectId` keeps the same name on both
sides, and INTEGER affinity converts any existing text value. Verified
that a stored text '1' comes back as integer 1 after the rebuild.
`tickets.userId` pointed at no table, so a ticket could name a user
that does not exist. Adds the foreign key with `set null` and drops
NOT NULL: a ticket is project work and outlives the account that
submitted it, so deleting a user clears the attribution rather than
destroying the ticket. Adds the relation on both sides and an index for
per-user ticket lookups.

Adds a unique index on `member (organizationId, userId)`. The plugin
already rejects a duplicate membership with
USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, so this changes no
existing behaviour; it closes the gap where two concurrent requests both
pass that check and insert.
The unique index on (organizationId, userId) means the existence check
is no longer what keeps a re-run from duplicating the row, so the insert
can use onConflictDoNothing and the comment claiming otherwise is gone.
Rebuilds tickets for the nullable set-null userId foreign key and adds
the two ticket indexes and the member unique index. The data-copy step
is correct as generated: the column names are unchanged on both sides.
Verified that a ticket survives its author being deleted with userId
nulled, and that a duplicate membership is now rejected.
nuxi writes this to record which module setup versions it resolved, so
the file appeared as untracked noise on every run. Tracking it pins the
@nuxt/test-utils setup version alongside the dependency in package.json.

Note this file is machine-written: if nuxi rewrites it, that surfaces as
a diff rather than being silently ignored.
@jasonappah jasonappah changed the title DRAFT database schema Database schema Oct 1, 2026
@jasonappah
jasonappah marked this pull request as ready for review October 1, 2026 20:17
Records which GitHub issue a ticket is linked to, per REQ-F-14.

Nullable rather than NOT NULL: a ticket has to exist before it can be
linked. REQ-F-09 and REQ-F-13 create tickets in ITS (partner form and
embeddable widget), and REQ-F-19 defers a ticket for partner review
before forwarding, so requiring an issue at insert time would block
those paths. Filled in when the link is made instead.

Unique, which SQLite enforces for non-null values while treating NULLs
as distinct: unlinked tickets coexist and no two tickets claim the same
issue.
Replaces plugin-managed organizations and roles with a plain
`user.orgId` relation read by application code. Nothing consumes the
plugin endpoints any more.

Drops shared/access-control.ts along with them: the `organization`,
`member`, and `ac` resource keys existed to gate plugin endpoints, and
the ticket/project grants are re-derived from `orgId` instead of a role
statement. The client loses `organizationClient` and `adminClient`
for the same reason.
…tables

Replaces plugin-managed membership with a single nullable `user.orgId`
foreign key. A null orgId is the whole role model: null means global
admin, set means the user belongs to that partner.

Drops `member`, `invitation`, and `organizationRole`. `member` goes
because its columns were organizationId, userId, and role -- the first
two are now one column on user and the third no longer exists. `invitation`
only served the plugin's invite flow, and `organizationRole` only held
dynamic access control statements.

Drops the plugin-owned columns `user.role`, `user.banned`,
`user.banReason`, `user.banExpires`, `session.activeOrganizationId`,
and `session.impersonatedBy`, all of which only the removed plugins
populated.

No onDelete on `orgId`: SET NULL would promote every ex-member to
global admin if a partner were deleted, and CASCADE would delete its
users, so NO ACTION blocks deleting a partner that still has users.
Not unique, since a partner has many users and each user has at most
one.
Replaces the member insert with an update of `user.orgId`, which is
what makes the seeded user an org user instead of a global admin. The
update is skipped when already correct so a re-run stays a no-op.
Drops the member, invitation, and organizationRole tables, adds
`user.orgId`, and drops the six plugin-owned columns.

Uses ALTER TABLE DROP COLUMN rather than a table rebuild, so there is no
data-copy step and no risk of the mapping bug seen in the project
rename migration. The one user row survived.

Verified that better-auth still resolves its models against the trimmed
schema (getSession and signOut both respond), that a partner with users
cannot be deleted, that a bogus orgId is rejected, and that an unused
partner still can be.
Both were organization-plugin fields: the slug existed for the plugin's
URL routing and its unique index, the logo for plugin-managed imagery.
Nothing populates or reads either now that the plugin is gone.

The seed keyed its idempotency check on slug, so it now looks the
partner up by name and the insert carries only a name. Note this makes
the seed sensitive to PARTNER_NAME: changing that constant orphans the
existing row and the next run adds a second partner, since there is no
longer a stable key column to look up by.
Drops the unique index before the column it covered, which SQLite
requires. The seeded partner row survived and user.orgId still
references organization.id.
@jasonappah
jasonappah merged commit a0ee6bd into dev Oct 1, 2026
1 check passed
@jasonappah
jasonappah deleted the dbSchema branch October 1, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DX/Feature: Design initial database schema for users, roles, organizations, projects and tickets

2 participants