1121ab7199
Sistema web para gestión de préstamos de material del Laboratorio de Sistemas Computacionales de la UABC. - Backend: Supabase self-hosted, schema aislado `prestamos` con RLS, triggers de stock y audit log (supabase/migrations/0001_init.sql). - Auth: Google OAuth restringido a @uabc.edu.mx, verificado en middleware y como segunda línea en trigger de DB. - Frontend: Astro 7 (SSR con adapter Node) + React islands + Tailwind v4 con paleta UABC (primary #00723F, secondary #DD971A) bajo regla 60/30/10. - Interfaz alumno mobile-first: catálogo con filtro por categorías, solicitud de préstamos, historial personal. - Interfaz admin desktop-first: panel con KPIs, bandeja de solicitudes (aprobar/rechazar/devolver), CRUD de inventario y categorías, reportes filtrables con export CSV nativo. - Modales con `<dialog>` nativo, cero librerías de UI adicionales. - Deploy: Dockerfile multi-stage node:22-alpine + docker-compose para publicar bajo prestamos.buglabs.dev vía Cloudflare Tunnel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
9.6 KiB
PL/PgSQL
271 lines
9.6 KiB
PL/PgSQL
-- 0001_init.sql
|
|
-- Sistema de Prestamos - Laboratorio de Sistemas Computacionales UABC
|
|
-- Schema aislado 'prestamos'. Coexiste con otras apps que usan 'public'.
|
|
|
|
begin;
|
|
|
|
create schema if not exists prestamos;
|
|
grant usage on schema prestamos to anon, authenticated, service_role;
|
|
|
|
-- ============================================================
|
|
-- Perfiles
|
|
-- ============================================================
|
|
create table prestamos.profiles (
|
|
id uuid primary key references auth.users(id) on delete cascade,
|
|
email text unique not null,
|
|
nombre text,
|
|
matricula text,
|
|
rol text not null default 'alumno' check (rol in ('alumno','admin')),
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
-- ============================================================
|
|
-- Categorias e inventario
|
|
-- ============================================================
|
|
create table prestamos.categorias (
|
|
id serial primary key,
|
|
nombre text unique not null
|
|
);
|
|
|
|
create table prestamos.materiales (
|
|
id serial primary key,
|
|
nombre text not null,
|
|
categoria_id int references prestamos.categorias(id) on delete restrict,
|
|
descripcion text,
|
|
cantidad_total int not null check (cantidad_total >= 0),
|
|
cantidad_disponible int not null check (cantidad_disponible >= 0),
|
|
numero_inventario text unique,
|
|
estado text not null default 'disponible' check (estado in ('disponible','mantenimiento','baja')),
|
|
created_at timestamptz not null default now(),
|
|
constraint disponibilidad_no_excede check (cantidad_disponible <= cantidad_total)
|
|
);
|
|
|
|
-- ============================================================
|
|
-- Prestamos
|
|
-- ============================================================
|
|
create table prestamos.prestamos (
|
|
id bigserial primary key,
|
|
alumno_id uuid not null references prestamos.profiles(id),
|
|
material_id int not null references prestamos.materiales(id),
|
|
cantidad int not null check (cantidad > 0),
|
|
estado text not null default 'pendiente'
|
|
check (estado in ('pendiente','aprobado','rechazado','activo','devuelto','vencido')),
|
|
fecha_solicitud timestamptz not null default now(),
|
|
fecha_aprobacion timestamptz,
|
|
fecha_devolucion_estimada date,
|
|
fecha_devolucion_real timestamptz,
|
|
aprobado_por uuid references prestamos.profiles(id),
|
|
notas text
|
|
);
|
|
|
|
create index prestamos_alumno_idx on prestamos.prestamos(alumno_id);
|
|
create index prestamos_estado_idx on prestamos.prestamos(estado);
|
|
create index prestamos_material_idx on prestamos.prestamos(material_id);
|
|
|
|
-- ============================================================
|
|
-- Audit log
|
|
-- ============================================================
|
|
create table prestamos.audit_log (
|
|
id bigserial primary key,
|
|
prestamo_id bigint references prestamos.prestamos(id) on delete cascade,
|
|
actor_id uuid references prestamos.profiles(id),
|
|
accion text not null,
|
|
estado_anterior text,
|
|
estado_nuevo text,
|
|
at timestamptz not null default now(),
|
|
payload jsonb
|
|
);
|
|
|
|
create index audit_prestamo_idx on prestamos.audit_log(prestamo_id);
|
|
create index audit_at_idx on prestamos.audit_log(at desc);
|
|
|
|
-- ============================================================
|
|
-- Helpers
|
|
-- ============================================================
|
|
create or replace function prestamos.is_admin()
|
|
returns boolean
|
|
language sql
|
|
stable
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
select coalesce((select rol = 'admin' from prestamos.profiles where id = auth.uid()), false);
|
|
$$;
|
|
|
|
grant execute on function prestamos.is_admin() to anon, authenticated;
|
|
|
|
-- ============================================================
|
|
-- Trigger: crear perfil al registrar usuario en auth.users
|
|
-- Rechaza emails no @uabc.edu.mx (segunda linea de defensa)
|
|
-- ============================================================
|
|
create or replace function prestamos.handle_new_user()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
begin
|
|
if new.email is null or new.email !~* '@uabc\.edu\.mx$' then
|
|
raise exception 'dominio_no_permitido: solo se permite @uabc.edu.mx';
|
|
end if;
|
|
|
|
insert into prestamos.profiles (id, email, nombre)
|
|
values (
|
|
new.id,
|
|
new.email,
|
|
coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name')
|
|
)
|
|
on conflict (id) do nothing;
|
|
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
-- ponytail: trigger nombrado con prefijo 'prestamos_' para no colisionar con
|
|
-- posibles triggers de otras apps sobre auth.users
|
|
drop trigger if exists prestamos_on_auth_user_created on auth.users;
|
|
create trigger prestamos_on_auth_user_created
|
|
after insert on auth.users
|
|
for each row execute function prestamos.handle_new_user();
|
|
|
|
-- ============================================================
|
|
-- Trigger: mantener cantidad_disponible al cambiar estado del prestamo
|
|
-- ============================================================
|
|
create or replace function prestamos.sync_stock()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
begin
|
|
-- transiciones que sacan stock
|
|
if new.estado in ('aprobado','activo') and old.estado not in ('aprobado','activo') then
|
|
update prestamos.materiales
|
|
set cantidad_disponible = cantidad_disponible - new.cantidad
|
|
where id = new.material_id;
|
|
end if;
|
|
|
|
-- transiciones que devuelven stock
|
|
if new.estado in ('devuelto','rechazado') and old.estado in ('aprobado','activo') then
|
|
update prestamos.materiales
|
|
set cantidad_disponible = cantidad_disponible + new.cantidad
|
|
where id = new.material_id;
|
|
end if;
|
|
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists prestamos_sync_stock on prestamos.prestamos;
|
|
create trigger prestamos_sync_stock
|
|
after update of estado on prestamos.prestamos
|
|
for each row execute function prestamos.sync_stock();
|
|
|
|
-- ============================================================
|
|
-- Trigger: audit log cuando cambia estado
|
|
-- ============================================================
|
|
create or replace function prestamos.log_estado()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = prestamos, pg_temp
|
|
as $$
|
|
begin
|
|
if new.estado is distinct from old.estado then
|
|
insert into prestamos.audit_log (prestamo_id, actor_id, accion, estado_anterior, estado_nuevo)
|
|
values (new.id, auth.uid(), 'cambio_estado', old.estado, new.estado);
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists prestamos_log_estado on prestamos.prestamos;
|
|
create trigger prestamos_log_estado
|
|
after update of estado on prestamos.prestamos
|
|
for each row execute function prestamos.log_estado();
|
|
|
|
-- ============================================================
|
|
-- RLS
|
|
-- ============================================================
|
|
alter table prestamos.profiles enable row level security;
|
|
alter table prestamos.categorias enable row level security;
|
|
alter table prestamos.materiales enable row level security;
|
|
alter table prestamos.prestamos enable row level security;
|
|
alter table prestamos.audit_log enable row level security;
|
|
|
|
-- profiles
|
|
create policy profiles_select_self_or_admin on prestamos.profiles
|
|
for select to authenticated
|
|
using (id = auth.uid() or prestamos.is_admin());
|
|
|
|
create policy profiles_update_self on prestamos.profiles
|
|
for update to authenticated
|
|
using (id = auth.uid())
|
|
with check (id = auth.uid() and rol = (select rol from prestamos.profiles where id = auth.uid()));
|
|
-- el usuario NO puede cambiar su propio rol; admin lo hace via service_role o via Studio
|
|
|
|
create policy profiles_admin_all on prestamos.profiles
|
|
for all to authenticated
|
|
using (prestamos.is_admin())
|
|
with check (prestamos.is_admin());
|
|
|
|
-- categorias
|
|
create policy categorias_read_all on prestamos.categorias
|
|
for select to authenticated using (true);
|
|
|
|
create policy categorias_admin_write on prestamos.categorias
|
|
for all to authenticated
|
|
using (prestamos.is_admin())
|
|
with check (prestamos.is_admin());
|
|
|
|
-- materiales
|
|
create policy materiales_read_all on prestamos.materiales
|
|
for select to authenticated using (true);
|
|
|
|
create policy materiales_admin_write on prestamos.materiales
|
|
for all to authenticated
|
|
using (prestamos.is_admin())
|
|
with check (prestamos.is_admin());
|
|
|
|
-- prestamos
|
|
create policy prestamos_select_own_or_admin on prestamos.prestamos
|
|
for select to authenticated
|
|
using (alumno_id = auth.uid() or prestamos.is_admin());
|
|
|
|
create policy prestamos_insert_own on prestamos.prestamos
|
|
for insert to authenticated
|
|
with check (alumno_id = auth.uid() and estado = 'pendiente');
|
|
|
|
create policy prestamos_update_admin on prestamos.prestamos
|
|
for update to authenticated
|
|
using (prestamos.is_admin())
|
|
with check (prestamos.is_admin());
|
|
|
|
-- audit_log
|
|
create policy audit_admin_read on prestamos.audit_log
|
|
for select to authenticated using (prestamos.is_admin());
|
|
-- INSERT solo via triggers security definer -> no policy publica
|
|
|
|
-- ============================================================
|
|
-- Grants (RLS ya filtra; se otorgan a authenticated para operar)
|
|
-- ============================================================
|
|
grant select, insert, update, delete on all tables in schema prestamos to authenticated, service_role;
|
|
grant usage, select on all sequences in schema prestamos to authenticated, service_role;
|
|
alter default privileges in schema prestamos
|
|
grant select, insert, update, delete on tables to authenticated, service_role;
|
|
grant select on prestamos.audit_log to authenticated; -- redundante pero explicito
|
|
|
|
-- ============================================================
|
|
-- Seed: categorias iniciales
|
|
-- ============================================================
|
|
insert into prestamos.categorias (nombre) values
|
|
('Cables'),
|
|
('Herramientas'),
|
|
('Componentes electronicos'),
|
|
('Equipo de medicion'),
|
|
('Kits de desarrollo'),
|
|
('Otros')
|
|
on conflict (nombre) do nothing;
|
|
|
|
commit;
|