File size: 6,796 Bytes
3493993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
-- Authoritative workspace-owned Project foundation.
-- Apply after app/security/migrations/0002_authoritative_tenancy_postgres.sql.
-- IDs remain UUID values stored as text to match MediaRouter's existing
-- tenancy and canonical-asset foreign-key types.

begin;
create extension if not exists pgcrypto;

create table if not exists projects (
  id text primary key default gen_random_uuid()::text,
  workspace_id text not null references workspaces(id) on delete restrict,
  created_by text not null references users(id) on delete restrict,
  name text not null check (char_length(name) between 1 and 200),
  description text check (description is null or char_length(description) <= 4000),
  status text not null default 'active' check (status in ('active', 'archived')),
  thumbnail_asset_id text references media_assets(id) on delete set null,
  metadata jsonb not null default '{}'::jsonb
    check (jsonb_typeof(metadata) = 'object'),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  archived_at timestamptz,
  constraint ck_projects_archive_timestamp check (
    (status = 'active' and archived_at is null)
    or (status = 'archived' and archived_at is not null)
  )
);
create index if not exists ix_projects_workspace on projects(workspace_id);
create index if not exists ix_projects_workspace_status on projects(workspace_id, status);
create index if not exists ix_projects_workspace_updated on projects(workspace_id, updated_at desc);
create index if not exists ix_projects_created_by on projects(created_by);

-- The shared AuditService owns this generic event table. It is deliberately
-- not project-specific so future domains can reuse the same safe boundary.
create table if not exists audit_events (
  id text primary key default gen_random_uuid()::text,
  workspace_id text not null references workspaces(id) on delete restrict,
  actor_user_id text references users(id) on delete set null,
  api_key_id text references api_keys(id) on delete set null,
  event_type text not null check (char_length(event_type) between 1 and 100),
  entity_type text not null check (char_length(entity_type) between 1 and 64),
  entity_id text not null,
  request_id text,
  metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'),
  created_at timestamptz not null default now()
);
create index if not exists ix_audit_events_workspace_created
  on audit_events(workspace_id, created_at desc);
create index if not exists ix_audit_events_type on audit_events(event_type);
create index if not exists ix_audit_events_entity on audit_events(entity_type, entity_id);

drop trigger if exists mediarouter_tenant_touch_updated_at on projects;
create trigger mediarouter_tenant_touch_updated_at
before update on projects
for each row execute function mediarouter_tenant_touch_updated_at();

create or replace function mediarouter_assert_project_ownership()
returns trigger language plpgsql as $$
declare asset_workspace text;
begin
  if tg_op = 'UPDATE' then
    if new.workspace_id is distinct from old.workspace_id
       or new.created_by is distinct from old.created_by then
      raise exception 'project ownership fields are immutable' using errcode = '23514';
    end if;
  end if;
  if new.thumbnail_asset_id is not null then
    select workspace_id into asset_workspace from media_assets
      where id = new.thumbnail_asset_id;
    if asset_workspace is null or asset_workspace is distinct from new.workspace_id then
      raise exception 'project thumbnail must belong to its workspace' using errcode = '23503';
    end if;
  end if;
  return new;
end;
$$;
drop trigger if exists mediarouter_project_ownership on projects;
create trigger mediarouter_project_ownership
before insert or update of workspace_id, created_by, thumbnail_asset_id on projects
for each row execute function mediarouter_assert_project_ownership();

alter table projects enable row level security;
alter table projects force row level security;

drop policy if exists projects_select on projects;
create policy projects_select on projects for select using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = projects.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);

drop policy if exists projects_insert on projects;
create policy projects_insert on projects for insert with check (
  workspace_id = current_setting('app.workspace_id', true)
  and created_by = current_setting('app.user_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = projects.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);

drop policy if exists projects_update on projects;
create policy projects_update on projects for update using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = projects.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
) with check (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = projects.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);

drop policy if exists projects_delete on projects;
create policy projects_delete on projects for delete using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = projects.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);

alter table audit_events enable row level security;
alter table audit_events force row level security;
drop policy if exists audit_events_select on audit_events;
create policy audit_events_select on audit_events for select using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = audit_events.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);
drop policy if exists audit_events_insert on audit_events;
create policy audit_events_insert on audit_events for insert with check (
  workspace_id = current_setting('app.workspace_id', true)
  and actor_user_id = current_setting('app.user_id', true)
  and exists (
    select 1 from workspace_memberships m
    where m.workspace_id = audit_events.workspace_id
      and m.user_id = current_setting('app.user_id', true)
      and m.status = 'active'
  )
);

commit;