Add db migrations + tests
This commit is contained in:
parent
f4705f23a7
commit
e1f673be47
7 changed files with 941 additions and 9 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { createClient } from "@supabase/supabase-js";
|
||||
import { testClient } from "hono/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createConfig } from "../../config.js";
|
||||
|
|
@ -35,6 +36,19 @@ vi.mock("stream-chat", () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock nodemailer for email sending
|
||||
const mockSendMail = vi.fn();
|
||||
vi.mock("nodemailer", () => ({
|
||||
default: {
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: mockSendMail,
|
||||
})),
|
||||
},
|
||||
createTransport: vi.fn(() => ({
|
||||
sendMail: mockSendMail,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Tablo Endpoint", () => {
|
||||
// In test mode, createConfig() reads from .env.test
|
||||
const config = createConfig();
|
||||
|
|
@ -54,6 +68,7 @@ describe("Tablo Endpoint", () => {
|
|||
mockChannelDelete.mockResolvedValue(undefined);
|
||||
mockChannelRemoveMembers.mockResolvedValue(undefined);
|
||||
mockChannelAddMembers.mockResolvedValue(undefined);
|
||||
mockSendMail.mockResolvedValue({ messageId: "test-message-id" });
|
||||
});
|
||||
|
||||
// Helper function to create tablo
|
||||
|
|
@ -147,6 +162,28 @@ describe("Tablo Endpoint", () => {
|
|||
);
|
||||
};
|
||||
|
||||
// Helper function to invite user to tablo
|
||||
const inviteToTabloRequest = async (
|
||||
user: TestUserData,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
|
||||
client: any,
|
||||
tabloId: string,
|
||||
email: string
|
||||
) => {
|
||||
return await client.tablos.invite[":tabloId"].$post(
|
||||
{
|
||||
param: { tabloId },
|
||||
json: { email },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
describe("POST /tablos/create - Create Tablo", () => {
|
||||
it("should allow owner to create a tablo and create a Stream Chat channel", async () => {
|
||||
const res = await createTabloRequest(ownerUser, client, {
|
||||
|
|
@ -436,4 +473,100 @@ describe("Tablo Endpoint", () => {
|
|||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tablos/invite/:tabloId - Invite User to Tablo", () => {
|
||||
it("should create a notification when inviting an existing user to a tablo", async () => {
|
||||
// Create a Supabase client to query the database
|
||||
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
// Get temp user's email
|
||||
const tempUserEmail = temporaryUser.email;
|
||||
|
||||
// Count notifications for temp user before invite
|
||||
const { data: notificationsBefore } = await supabaseAdmin
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.eq("user_id", temporaryUser.userId)
|
||||
.eq("entity_type", "tablo_invites");
|
||||
|
||||
const countBefore = notificationsBefore?.length || 0;
|
||||
|
||||
// Owner invites temp user to owner's private tablo (temp doesn't have access yet)
|
||||
const res = await inviteToTabloRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
tempUserEmail
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toContain("sent successfully");
|
||||
|
||||
// Query notifications table to verify notification was created
|
||||
const { data: notificationsAfter } = await supabaseAdmin
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.eq("user_id", temporaryUser.userId)
|
||||
.eq("entity_type", "tablo_invites")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
expect(notificationsAfter).toBeDefined();
|
||||
expect(notificationsAfter?.length).toBeGreaterThan(countBefore);
|
||||
|
||||
// Verify the latest notification has correct properties
|
||||
const latestNotification = notificationsAfter?.[0];
|
||||
expect(latestNotification).toBeDefined();
|
||||
expect(latestNotification?.entity_type).toBe("tablo_invites");
|
||||
expect(latestNotification?.user_id).toBe(temporaryUser.userId);
|
||||
expect(latestNotification?.actor_id).toBe(ownerUser.userId);
|
||||
expect(latestNotification?.action_type).toBe("created");
|
||||
expect(latestNotification?.message).toContain("invited");
|
||||
expect(latestNotification?.read_at).toBeNull();
|
||||
});
|
||||
|
||||
it("should create notification when inviting non-existent user (creates temporary account)", async () => {
|
||||
// Create a Supabase client to query the database
|
||||
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
const nonExistentEmail = "nonexistent_user_test@example.com";
|
||||
|
||||
// Owner invites non-existent user to owner's private tablo
|
||||
const res = await inviteToTabloRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private_2",
|
||||
nonExistentEmail
|
||||
);
|
||||
|
||||
// Request should succeed (creates invite)
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.message).toContain("sent successfully");
|
||||
|
||||
// Verify user was created with a temporary account
|
||||
const { data: authUsers } = await supabaseAdmin.auth.admin.listUsers();
|
||||
const createdUser = authUsers.users.find(
|
||||
(u: { email: string }) => u.email === nonExistentEmail
|
||||
);
|
||||
expect(createdUser).toBeDefined();
|
||||
|
||||
// Check if notification was created for the newly created user
|
||||
// Since the system creates a temporary account, a notification should be created
|
||||
const { data: notificationsForInvite } = await supabaseAdmin
|
||||
.from("notifications")
|
||||
.select("*")
|
||||
.eq("user_id", createdUser?.id)
|
||||
.eq("entity_type", "tablo_invites")
|
||||
.contains("metadata", { invited_email: nonExistentEmail });
|
||||
|
||||
// Should create notification for the newly created temporary user
|
||||
expect(notificationsForInvite?.length || 0).toBeGreaterThan(0);
|
||||
expect(notificationsForInvite?.[0].message).toContain("invited");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -386,8 +386,9 @@ describe("TabloData Endpoint", () => {
|
|||
expect(data.message).toBe("File deleted successfully");
|
||||
});
|
||||
|
||||
it("should deny owner with admin access to delete file from temp's tablo", async () => {
|
||||
// Owner does not have admin access to test_tablo_temp_shared_admin
|
||||
it("should deny owner from deleting file from temp's tablo (regularUserCheck blocks temporary owner)", async () => {
|
||||
// Owner has admin access to test_tablo_temp_shared_admin
|
||||
// BUT regularUserCheck blocks access to tablos owned by temporary users
|
||||
const res = await deleteTabloFileRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
|
|
|
|||
|
|
@ -248,11 +248,12 @@ BEGIN
|
|||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Find the user by email and notify them
|
||||
-- Find the user by email and notify them (case-insensitive)
|
||||
-- Only notify if user exists in the system
|
||||
affected_users := ARRAY(
|
||||
SELECT id
|
||||
FROM auth.users
|
||||
WHERE email = NEW.invited_email
|
||||
WHERE LOWER(email) = LOWER(NEW.invited_email)
|
||||
AND id != actor
|
||||
);
|
||||
|
||||
|
|
|
|||
262
supabase/migrations/20251116175557_update_notif_trigger.sql
Normal file
262
supabase/migrations/20251116175557_update_notif_trigger.sql
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
CREATE OR REPLACE FUNCTION public.notify_users()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
affected_users UUID[];
|
||||
affected_user UUID;
|
||||
actor UUID;
|
||||
action TEXT;
|
||||
msg TEXT;
|
||||
meta JSONB;
|
||||
entity_type_name TEXT;
|
||||
entity_identifier TEXT;
|
||||
BEGIN
|
||||
-- Determine if this is an INSERT or UPDATE
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
action := 'created';
|
||||
ELSE
|
||||
action := 'updated';
|
||||
-- Skip if soft delete happened (only for tables with deleted_at column)
|
||||
IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Get the actor (current user)
|
||||
actor := auth.uid();
|
||||
|
||||
-- Set entity type and ID based on the table
|
||||
entity_type_name := TG_TABLE_NAME;
|
||||
|
||||
-- Determine entity ID and affected users based on table
|
||||
CASE TG_TABLE_NAME
|
||||
WHEN 'tablos' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New tablo "' || NEW.name || '" was created';
|
||||
-- Notify owner (but not if they are the creator)
|
||||
IF NEW.owner_id != actor THEN
|
||||
affected_users := ARRAY[NEW.owner_id];
|
||||
END IF;
|
||||
ELSE
|
||||
msg := 'Tablo "' || NEW.name || '" was updated';
|
||||
-- Notify owner and all collaborators
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.id
|
||||
AND is_active = true
|
||||
AND user_id != actor
|
||||
UNION
|
||||
SELECT NEW.owner_id
|
||||
WHERE NEW.owner_id != actor
|
||||
);
|
||||
END IF;
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_name', NEW.name,
|
||||
'status', NEW.status,
|
||||
'color', NEW.color
|
||||
);
|
||||
|
||||
WHEN 'tasks' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New task "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
IF OLD.status != NEW.status THEN
|
||||
msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status;
|
||||
ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN
|
||||
msg := 'You were assigned to task "' || NEW.title || '"';
|
||||
ELSE
|
||||
msg := 'Task "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Notify tablo collaborators and assignee
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.tablo_id
|
||||
AND is_active = true
|
||||
AND user_id != actor
|
||||
UNION
|
||||
SELECT t.owner_id
|
||||
FROM public.tablos t
|
||||
WHERE t.id = NEW.tablo_id
|
||||
AND t.owner_id != actor
|
||||
UNION
|
||||
SELECT NEW.assignee_id
|
||||
WHERE NEW.assignee_id IS NOT NULL
|
||||
AND NEW.assignee_id != actor
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'task_title', NEW.title,
|
||||
'status', NEW.status,
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'assignee_id', NEW.assignee_id
|
||||
);
|
||||
|
||||
WHEN 'events' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New event "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
msg := 'Event "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
|
||||
-- Notify tablo collaborators
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.tablo_id
|
||||
AND is_active = true
|
||||
AND user_id != actor
|
||||
UNION
|
||||
SELECT t.owner_id
|
||||
FROM public.tablos t
|
||||
WHERE t.id = NEW.tablo_id
|
||||
AND t.owner_id != actor
|
||||
UNION
|
||||
SELECT NEW.created_by
|
||||
WHERE NEW.created_by != actor
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'event_title', NEW.title,
|
||||
'start_date', NEW.start_date,
|
||||
'start_time', NEW.start_time,
|
||||
'tablo_id', NEW.tablo_id
|
||||
);
|
||||
|
||||
WHEN 'notes' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New note "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
msg := 'Note "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
|
||||
-- Notify note owner and users with access
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.note_access
|
||||
WHERE note_id = NEW.id
|
||||
AND is_active = true
|
||||
AND user_id != actor
|
||||
UNION
|
||||
SELECT NEW.user_id
|
||||
WHERE NEW.user_id != actor
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'note_title', NEW.title,
|
||||
'note_owner', NEW.user_id
|
||||
);
|
||||
|
||||
WHEN 'tablo_access' THEN
|
||||
entity_identifier := NEW.id::TEXT;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'You were granted access to a tablo';
|
||||
ELSE
|
||||
IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN
|
||||
msg := 'You were promoted to admin on a tablo';
|
||||
ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN
|
||||
msg := 'Your access to a tablo was revoked';
|
||||
ELSE
|
||||
msg := 'Your tablo access was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Notify the user being granted/modified access
|
||||
IF NEW.user_id != actor THEN
|
||||
affected_users := ARRAY[NEW.user_id];
|
||||
END IF;
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'is_admin', NEW.is_admin,
|
||||
'is_active', NEW.is_active,
|
||||
'granted_by', NEW.granted_by
|
||||
);
|
||||
|
||||
WHEN 'tablo_invites' THEN
|
||||
entity_identifier := NEW.id::TEXT;
|
||||
|
||||
RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%', TG_OP, NEW.invited_email;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'You were invited to collaborate on a tablo';
|
||||
ELSE
|
||||
IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN
|
||||
msg := 'Your tablo invitation status changed';
|
||||
ELSE
|
||||
msg := 'Your tablo invitation was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor;
|
||||
|
||||
-- Find the user by email and notify them (case-insensitive)
|
||||
-- Only notify if user exists in the system
|
||||
affected_users := ARRAY(
|
||||
SELECT id
|
||||
FROM auth.users
|
||||
WHERE LOWER(email) = LOWER(NEW.invited_email)
|
||||
AND id != actor
|
||||
);
|
||||
|
||||
RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'invited_email', NEW.invited_email,
|
||||
'invited_by', NEW.invited_by,
|
||||
'is_pending', NEW.is_pending
|
||||
);
|
||||
|
||||
ELSE
|
||||
-- Unknown table, skip
|
||||
RETURN NEW;
|
||||
END CASE;
|
||||
|
||||
-- Insert notifications for all affected users
|
||||
RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name;
|
||||
|
||||
IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN
|
||||
FOREACH affected_user IN ARRAY affected_users
|
||||
LOOP
|
||||
RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg;
|
||||
|
||||
INSERT INTO public.notifications (
|
||||
user_id,
|
||||
actor_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
action_type,
|
||||
message,
|
||||
metadata
|
||||
) VALUES (
|
||||
affected_user,
|
||||
actor,
|
||||
entity_type_name,
|
||||
entity_identifier,
|
||||
action,
|
||||
msg,
|
||||
meta
|
||||
);
|
||||
END LOOP;
|
||||
ELSE
|
||||
RAISE LOG 'notify_users: No affected users to notify';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
270
supabase/migrations/20251116185449_fix_actor_notif_trigger.sql
Normal file
270
supabase/migrations/20251116185449_fix_actor_notif_trigger.sql
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
CREATE OR REPLACE FUNCTION public.notify_users()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
affected_users UUID[];
|
||||
affected_user UUID;
|
||||
actor UUID;
|
||||
action TEXT;
|
||||
msg TEXT;
|
||||
meta JSONB;
|
||||
entity_type_name TEXT;
|
||||
entity_identifier TEXT;
|
||||
BEGIN
|
||||
-- Determine if this is an INSERT or UPDATE
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
action := 'created';
|
||||
ELSE
|
||||
action := 'updated';
|
||||
-- Skip if soft delete happened (only for tables with deleted_at column)
|
||||
IF TG_TABLE_NAME IN ('tablos', 'events', 'notes', 'event_types') THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Get the actor (current user)
|
||||
-- Note: auth.uid() may return NULL when called via service role (API calls)
|
||||
-- In such cases, we won't exclude anyone from notifications based on actor
|
||||
actor := auth.uid();
|
||||
|
||||
-- Set entity type and ID based on the table
|
||||
entity_type_name := TG_TABLE_NAME;
|
||||
|
||||
-- Determine entity ID and affected users based on table
|
||||
CASE TG_TABLE_NAME
|
||||
WHEN 'tablos' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New tablo "' || NEW.name || '" was created';
|
||||
-- Notify owner (but not if they are the creator)
|
||||
IF actor IS NULL OR NEW.owner_id != actor THEN
|
||||
affected_users := ARRAY[NEW.owner_id];
|
||||
END IF;
|
||||
ELSE
|
||||
msg := 'Tablo "' || NEW.name || '" was updated';
|
||||
-- Notify owner and all collaborators (exclude actor if set)
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.id
|
||||
AND is_active = true
|
||||
AND (actor IS NULL OR user_id != actor)
|
||||
UNION
|
||||
SELECT NEW.owner_id
|
||||
WHERE actor IS NULL OR NEW.owner_id != actor
|
||||
);
|
||||
END IF;
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_name', NEW.name,
|
||||
'status', NEW.status,
|
||||
'color', NEW.color
|
||||
);
|
||||
|
||||
WHEN 'tasks' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New task "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
IF OLD.status != NEW.status THEN
|
||||
msg := 'Task "' || NEW.title || '" status changed to ' || NEW.status;
|
||||
ELSIF OLD.assignee_id != NEW.assignee_id OR (OLD.assignee_id IS NULL AND NEW.assignee_id IS NOT NULL) THEN
|
||||
msg := 'You were assigned to task "' || NEW.title || '"';
|
||||
ELSE
|
||||
msg := 'Task "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Notify tablo collaborators and assignee (exclude actor if set)
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.tablo_id
|
||||
AND is_active = true
|
||||
AND (actor IS NULL OR user_id != actor)
|
||||
UNION
|
||||
SELECT t.owner_id
|
||||
FROM public.tablos t
|
||||
WHERE t.id = NEW.tablo_id
|
||||
AND (actor IS NULL OR t.owner_id != actor)
|
||||
UNION
|
||||
SELECT NEW.assignee_id
|
||||
WHERE NEW.assignee_id IS NOT NULL
|
||||
AND (actor IS NULL OR NEW.assignee_id != actor)
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'task_title', NEW.title,
|
||||
'status', NEW.status,
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'assignee_id', NEW.assignee_id
|
||||
);
|
||||
|
||||
WHEN 'events' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New event "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
msg := 'Event "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
|
||||
-- Notify tablo collaborators (exclude actor if set)
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.tablo_access
|
||||
WHERE tablo_id = NEW.tablo_id
|
||||
AND is_active = true
|
||||
AND (actor IS NULL OR user_id != actor)
|
||||
UNION
|
||||
SELECT t.owner_id
|
||||
FROM public.tablos t
|
||||
WHERE t.id = NEW.tablo_id
|
||||
AND (actor IS NULL OR t.owner_id != actor)
|
||||
UNION
|
||||
SELECT NEW.created_by
|
||||
WHERE actor IS NULL OR NEW.created_by != actor
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'event_title', NEW.title,
|
||||
'start_date', NEW.start_date,
|
||||
'start_time', NEW.start_time,
|
||||
'tablo_id', NEW.tablo_id
|
||||
);
|
||||
|
||||
WHEN 'notes' THEN
|
||||
entity_identifier := NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'New note "' || NEW.title || '" was created';
|
||||
ELSE
|
||||
msg := 'Note "' || NEW.title || '" was updated';
|
||||
END IF;
|
||||
|
||||
-- Notify note owner and users with access (exclude actor if set)
|
||||
affected_users := ARRAY(
|
||||
SELECT DISTINCT user_id
|
||||
FROM public.note_access
|
||||
WHERE note_id = NEW.id
|
||||
AND is_active = true
|
||||
AND (actor IS NULL OR user_id != actor)
|
||||
UNION
|
||||
SELECT NEW.user_id
|
||||
WHERE actor IS NULL OR NEW.user_id != actor
|
||||
);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'note_title', NEW.title,
|
||||
'note_owner', NEW.user_id
|
||||
);
|
||||
|
||||
WHEN 'tablo_access' THEN
|
||||
entity_identifier := NEW.id::TEXT;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'You were granted access to a tablo';
|
||||
ELSE
|
||||
IF OLD.is_admin != NEW.is_admin AND NEW.is_admin = true THEN
|
||||
msg := 'You were promoted to admin on a tablo';
|
||||
ELSIF OLD.is_active != NEW.is_active AND NEW.is_active = false THEN
|
||||
msg := 'Your access to a tablo was revoked';
|
||||
ELSE
|
||||
msg := 'Your tablo access was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Notify the user being granted/modified access (exclude actor if set)
|
||||
IF actor IS NULL OR NEW.user_id != actor THEN
|
||||
affected_users := ARRAY[NEW.user_id];
|
||||
END IF;
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'is_admin', NEW.is_admin,
|
||||
'is_active', NEW.is_active,
|
||||
'granted_by', NEW.granted_by
|
||||
);
|
||||
|
||||
WHEN 'tablo_invites' THEN
|
||||
entity_identifier := NEW.id::TEXT;
|
||||
|
||||
-- For tablo_invites, use invited_by as actor instead of auth.uid()
|
||||
-- because this is called via service role which has no auth context
|
||||
actor := NEW.invited_by;
|
||||
|
||||
RAISE LOG 'notify_users: New tablo invite: %', NEW;
|
||||
RAISE LOG 'notify_users: Processing tablo_invites, TG_OP=%, email=%, actor=%', TG_OP, NEW.invited_email, actor;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
msg := 'You were invited to collaborate on a tablo';
|
||||
ELSE
|
||||
IF OLD.is_pending != NEW.is_pending AND NEW.is_pending = false THEN
|
||||
msg := 'Your tablo invitation status changed';
|
||||
ELSE
|
||||
msg := 'Your tablo invitation was updated';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RAISE LOG 'notify_users: Looking for user with email=%, actor=%', NEW.invited_email, actor;
|
||||
|
||||
-- Find the user by email and notify them (case-insensitive)
|
||||
-- Only notify if user exists in the system
|
||||
-- Don't notify the person who sent the invite (exclude actor if set)
|
||||
affected_users := ARRAY(
|
||||
SELECT id
|
||||
FROM auth.users
|
||||
WHERE LOWER(email) = LOWER(NEW.invited_email)
|
||||
AND (actor IS NULL OR id != actor)
|
||||
);
|
||||
|
||||
RAISE LOG 'notify_users: Found % affected users for invite', COALESCE(array_length(affected_users, 1), 0);
|
||||
|
||||
meta := jsonb_build_object(
|
||||
'tablo_id', NEW.tablo_id,
|
||||
'invited_email', NEW.invited_email,
|
||||
'invited_by', NEW.invited_by,
|
||||
'is_pending', NEW.is_pending
|
||||
);
|
||||
|
||||
ELSE
|
||||
-- Unknown table, skip
|
||||
RETURN NEW;
|
||||
END CASE;
|
||||
|
||||
-- Insert notifications for all affected users
|
||||
RAISE LOG 'notify_users: About to insert notifications for % users, entity_type=%', COALESCE(array_length(affected_users, 1), 0), entity_type_name;
|
||||
|
||||
IF affected_users IS NOT NULL AND array_length(affected_users, 1) > 0 THEN
|
||||
FOREACH affected_user IN ARRAY affected_users
|
||||
LOOP
|
||||
RAISE LOG 'notify_users: Inserting notification for user_id=%, entity_type=%, message=%', affected_user, entity_type_name, msg;
|
||||
|
||||
INSERT INTO public.notifications (
|
||||
user_id,
|
||||
actor_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
action_type,
|
||||
message,
|
||||
metadata
|
||||
) VALUES (
|
||||
affected_user,
|
||||
actor,
|
||||
entity_type_name,
|
||||
entity_identifier,
|
||||
action,
|
||||
msg,
|
||||
meta
|
||||
);
|
||||
END LOOP;
|
||||
ELSE
|
||||
RAISE LOG 'notify_users: No affected users to notify';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
|
@ -109,7 +109,7 @@ supabase/tests/database/03_rls_policies_notes.test.sql .. ok
|
|||
supabase/tests/database/09_notifications.test.sql .. ok
|
||||
supabase/tests/database/10_rls_policies_notifications.test.sql .. ok
|
||||
All tests successful.
|
||||
Files=10, Tests=374, 1 wallclock secs
|
||||
Files=10, Tests=382, 1 wallclock secs
|
||||
Result: PASS
|
||||
```
|
||||
|
||||
|
|
@ -155,7 +155,7 @@ Use the pgTAP documentation for available test functions: https://pgtap.org/docu
|
|||
|
||||
## Total Test Count
|
||||
|
||||
- **374 tests** across 10 test files (63 notifications schema/triggers + 19 notifications RLS)
|
||||
- **382 tests** across 10 test files (71 notifications schema/triggers/NULL-actor + 19 notifications RLS)
|
||||
- Comprehensive coverage of all database components
|
||||
- Security-focused testing for RLS and permissions
|
||||
- Full notification system coverage including triggers and RLS
|
||||
- Full notification system coverage including triggers, RLS, and NULL actor (service role) scenarios
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
begin;
|
||||
select plan(63); -- Total number of tests (54 original + 9 multiple notifications tests)
|
||||
select plan(71); -- Total number of tests (54 original + 9 multiple notifications tests + 2 invite edge cases + 6 null actor tests)
|
||||
|
||||
-- ============================================================================
|
||||
-- Table Existence Tests
|
||||
|
|
@ -336,7 +336,77 @@ SELECT ok(
|
|||
(SELECT COUNT(*) FROM public.notifications
|
||||
WHERE entity_type = 'tablo_invites'
|
||||
AND user_id = current_setting('test.user2_id')::uuid) > 0,
|
||||
'Notification should be created when user is invited'
|
||||
'Notification should be created when existing user is invited'
|
||||
);
|
||||
|
||||
-- Test: No notification for non-existent user
|
||||
DO $$
|
||||
DECLARE
|
||||
count_before bigint;
|
||||
count_after bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
|
||||
|
||||
SELECT COUNT(*) INTO count_before FROM public.notifications;
|
||||
|
||||
-- Invite a non-existent user
|
||||
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
|
||||
VALUES (
|
||||
current_setting('test.tablo_id'),
|
||||
'nonexistent@example.com',
|
||||
current_setting('test.user1_id')::uuid,
|
||||
'test-token-' || gen_random_uuid()::text,
|
||||
true
|
||||
);
|
||||
|
||||
SELECT COUNT(*) INTO count_after FROM public.notifications;
|
||||
|
||||
PERFORM set_config('test.nonexistent_notif_diff', (count_after - count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.nonexistent_notif_diff', true)::integer,
|
||||
0,
|
||||
'No notification should be created when inviting non-existent user'
|
||||
);
|
||||
|
||||
-- Test: Case-insensitive email matching
|
||||
DO $$
|
||||
DECLARE
|
||||
count_before bigint;
|
||||
count_after bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', json_build_object('sub', current_setting('test.user1_id'))::text, true);
|
||||
|
||||
-- Count notifications for user2 before
|
||||
SELECT COUNT(*) INTO count_before
|
||||
FROM public.notifications
|
||||
WHERE entity_type = 'tablo_invites'
|
||||
AND user_id = current_setting('test.user2_id')::uuid;
|
||||
|
||||
-- Invite with uppercase email to test case-insensitivity
|
||||
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
|
||||
VALUES (
|
||||
current_setting('test.tablo_id'),
|
||||
UPPER('notiftest2_' || current_setting('test.user2_id') || '@test.com'),
|
||||
current_setting('test.user1_id')::uuid,
|
||||
'test-token-uppercase-' || gen_random_uuid()::text,
|
||||
true
|
||||
);
|
||||
|
||||
-- Count after
|
||||
SELECT COUNT(*) INTO count_after
|
||||
FROM public.notifications
|
||||
WHERE entity_type = 'tablo_invites'
|
||||
AND user_id = current_setting('test.user2_id')::uuid;
|
||||
|
||||
PERFORM set_config('test.case_insensitive_notif_diff', (count_after - count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.case_insensitive_notif_diff', true)::integer,
|
||||
1,
|
||||
'Notification should be created with case-insensitive email matching'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
|
|
@ -595,5 +665,200 @@ SELECT is(
|
|||
'Soft delete should not create additional notifications'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- NULL Actor Tests (Service Role / API Operations)
|
||||
-- ============================================================================
|
||||
|
||||
-- Test: Tablo update with NULL actor should notify all collaborators
|
||||
DO $$
|
||||
DECLARE
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
BEGIN
|
||||
-- Clear auth context (simulates service role call with no auth.uid())
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
-- Count notifications before update
|
||||
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
|
||||
|
||||
-- Update tablo without actor context
|
||||
UPDATE public.tablos
|
||||
SET name = 'Updated by Service Role'
|
||||
WHERE id = current_setting('test.tablo_id');
|
||||
|
||||
-- Count notifications after update
|
||||
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
|
||||
|
||||
PERFORM set_config('test.null_actor_notif_count', (notif_count_after - notif_count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_notif_count', true)::integer,
|
||||
5,
|
||||
'With NULL actor, all collaborators (including owner) should be notified on tablo update (5 total: user1-5)'
|
||||
);
|
||||
|
||||
-- Test: Task creation with NULL actor
|
||||
DO $$
|
||||
DECLARE
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
new_task_id text;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
|
||||
|
||||
-- Create task without actor context
|
||||
INSERT INTO public.tasks (tablo_id, title, description, status, position)
|
||||
VALUES (current_setting('test.tablo_id'), 'Service Role Task', 'Created via service role', 'todo', 2)
|
||||
RETURNING id INTO new_task_id;
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
|
||||
|
||||
PERFORM set_config('test.null_actor_task_notif_count', (notif_count_after - notif_count_before)::text, true);
|
||||
PERFORM set_config('test.null_actor_task_id', new_task_id, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_task_notif_count', true)::integer,
|
||||
5,
|
||||
'With NULL actor, all tablo members should be notified on task creation (owner + 4 collaborators)'
|
||||
);
|
||||
|
||||
-- Test: Tablo access grant with NULL actor
|
||||
DO $$
|
||||
DECLARE
|
||||
test_user6_id uuid := gen_random_uuid();
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
BEGIN
|
||||
-- Insert test user6
|
||||
INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, created_at, updated_at)
|
||||
VALUES
|
||||
(test_user6_id, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'notiftest6_' || test_user6_id::text || '@test.com', 'encrypted', now(), now(), now())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO public.profiles (id, email, first_name, last_name, short_user_id)
|
||||
VALUES
|
||||
(test_user6_id, 'notiftest6_' || test_user6_id::text || '@test.com', 'NotifTest', 'User6', substring(test_user6_id::text from 1 for 8))
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
PERFORM set_config('test.user6_id', test_user6_id::text, true);
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_before
|
||||
FROM public.notifications
|
||||
WHERE user_id = test_user6_id;
|
||||
|
||||
-- Grant access without actor context
|
||||
INSERT INTO public.tablo_access (tablo_id, user_id, granted_by, is_active, is_admin)
|
||||
VALUES (current_setting('test.tablo_id'), test_user6_id, current_setting('test.user1_id')::uuid, true, false);
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_after
|
||||
FROM public.notifications
|
||||
WHERE user_id = test_user6_id;
|
||||
|
||||
PERFORM set_config('test.null_actor_access_notif_diff', (notif_count_after - notif_count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_access_notif_diff', true)::integer,
|
||||
1,
|
||||
'With NULL actor, user should still be notified when granted tablo access'
|
||||
);
|
||||
|
||||
-- Test: Tablo invite with NULL actor (simulating API invite)
|
||||
DO $$
|
||||
DECLARE
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
-- Count notifications for user6 before invite
|
||||
SELECT COUNT(*) INTO notif_count_before
|
||||
FROM public.notifications
|
||||
WHERE user_id = current_setting('test.user6_id')::uuid
|
||||
AND entity_type = 'tablo_invites';
|
||||
|
||||
-- Invite user6 without actor context (via service role)
|
||||
INSERT INTO public.tablo_invites (tablo_id, invited_email, invited_by, invite_token, is_pending)
|
||||
VALUES (
|
||||
current_setting('test.tablo_id'),
|
||||
'notiftest6_' || current_setting('test.user6_id') || '@test.com',
|
||||
current_setting('test.user1_id')::uuid,
|
||||
'test-token-null-actor-' || gen_random_uuid()::text,
|
||||
true
|
||||
);
|
||||
|
||||
-- Count after
|
||||
SELECT COUNT(*) INTO notif_count_after
|
||||
FROM public.notifications
|
||||
WHERE user_id = current_setting('test.user6_id')::uuid
|
||||
AND entity_type = 'tablo_invites';
|
||||
|
||||
PERFORM set_config('test.null_actor_invite_notif_diff', (notif_count_after - notif_count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_invite_notif_diff', true)::integer,
|
||||
1,
|
||||
'With NULL actor, invited user should still receive notification (API-initiated invite)'
|
||||
);
|
||||
|
||||
-- Test: Event creation with NULL actor
|
||||
DO $$
|
||||
DECLARE
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
new_event_id text;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
|
||||
|
||||
-- Create event without actor context
|
||||
INSERT INTO public.events (tablo_id, title, description, start_date, start_time, created_by)
|
||||
VALUES (current_setting('test.tablo_id'), 'Service Role Event', 'Event via service role', CURRENT_DATE, '14:00', current_setting('test.user1_id')::uuid)
|
||||
RETURNING id INTO new_event_id;
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
|
||||
|
||||
PERFORM set_config('test.null_actor_event_notif_count', (notif_count_after - notif_count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_event_notif_count', true)::integer,
|
||||
6,
|
||||
'With NULL actor, all tablo members should be notified on event creation (owner + 5 collaborators including user6)'
|
||||
);
|
||||
|
||||
-- Test: Note update with NULL actor
|
||||
DO $$
|
||||
DECLARE
|
||||
notif_count_before bigint;
|
||||
notif_count_after bigint;
|
||||
BEGIN
|
||||
PERFORM set_config('request.jwt.claims', NULL, true);
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_before FROM public.notifications;
|
||||
|
||||
-- Update note without actor context
|
||||
UPDATE public.notes
|
||||
SET content = 'Updated by service role'
|
||||
WHERE id = current_setting('test.note_id');
|
||||
|
||||
SELECT COUNT(*) INTO notif_count_after FROM public.notifications;
|
||||
|
||||
PERFORM set_config('test.null_actor_note_notif_count', (notif_count_after - notif_count_before)::text, true);
|
||||
END $$;
|
||||
|
||||
SELECT is(
|
||||
current_setting('test.null_actor_note_notif_count', true)::integer,
|
||||
2,
|
||||
'With NULL actor, note owner (user1) and collaborator (user2) should both be notified on note update'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
|
|
|||
Loading…
Reference in a new issue