diff --git a/EDGE_FUNCTION_MIGRATION.md b/EDGE_FUNCTION_MIGRATION.md
new file mode 100644
index 0000000..9621f6b
--- /dev/null
+++ b/EDGE_FUNCTION_MIGRATION.md
@@ -0,0 +1,180 @@
+# Edge Function Migration Guide
+
+## Overview
+
+This document explains the migration from using database triggers to calling the edge function directly from the frontend. The edge function now handles all database operations, email notifications, and calendar entries in a single, secure location.
+
+## What Changed
+
+### Before (Database Triggers)
+1. Frontend creates/updates records in Supabase
+2. Database triggers automatically call the edge function
+3. Edge function sends emails and handles calendar entries
+
+### After (Direct Frontend Calls)
+1. Frontend sends data to edge function
+2. Edge function handles database operations, emails, and calendar entries
+3. Frontend updates local state based on edge function response
+
+## Updated Files
+
+### New Files
+- `src/utils/edgeFunctionUtils.ts` - Utility functions for calling the edge function
+
+### Modified Files
+- `src/hooks/useCreateBooking.ts` - Now sends booking data to edge function
+- `src/hooks/useCreateRequest.ts` - Now sends request data to edge function
+- `src/hooks/useApprovalOperations.ts` - Now sends approval data to edge function
+- `src/hooks/useRequestCommentOperations.ts` - Now sends comment data to edge function
+- `src/hooks/useCancellationOperations.ts` - Now sends cancellation data to edge function
+- `src/hooks/useCancelRequest.ts` - Now sends cancellation data to edge function
+- `src/hooks/useCompleteRequest.ts` - Now sends completion data to edge function
+- `bookingsystem-dbtrigger/supabase/functions/bookingnotifications/index.ts` - Now handles database operations
+
+## How It Works
+
+### Edge Function Responsibilities
+The edge function now handles all operations in a single place:
+
+1. **Database Operations** - Inserts/updates records in Supabase
+2. **Email Notifications** - Sends appropriate emails based on operation type
+3. **Calendar Entries** - Creates calendar events and Nostr posts
+4. **Error Handling** - Provides comprehensive error handling and logging
+
+### Frontend Integration
+The frontend sends data to the edge function and handles the response:
+
+```typescript
+// The Supabase client handles authentication automatically
+const { data, error } = await supabase.functions.invoke('bookingnotifications', {
+ body: payload,
+});
+```
+
+This approach:
+- Uses Supabase's built-in authentication
+- Doesn't require webhook signatures (which are deprecated)
+- Provides better error handling
+- Is more secure and reliable
+- Centralizes all business logic in the edge function
+
+### Payload Structure
+All edge function calls use the same payload structure:
+
+```typescript
+{
+ record: {
+ // Complete record data for database operations
+ },
+ type: 'new_booking' | 'confirmed_booking' | 'new_request' | 'new_request_comment'
+}
+```
+
+## Benefits
+
+1. **Better Security** - Database operations happen server-side with proper authentication
+2. **Centralized Logic** - All business logic in one place
+3. **Better Error Handling** - Comprehensive error handling and logging
+4. **No Database Dependencies** - Eliminates need for database triggers
+5. **Easier Testing** - Can test edge function calls independently
+6. **Modern Approach** - Uses current best practices instead of deprecated webhook signatures
+7. **Atomic Operations** - Database operations, emails, and calendar entries happen atomically
+
+## Migration Steps
+
+### 1. Deploy Updated Edge Function
+Deploy the updated edge function that handles database operations, emails, and calendar entries.
+
+### 2. Update Frontend Code
+The frontend code has been updated to send data to the edge function. No additional changes needed.
+
+### 3. Remove Database Triggers (Recommended)
+Since we're no longer using database triggers, you can remove them:
+
+1. Drop the trigger functions:
+ ```sql
+ DROP TRIGGER IF EXISTS on_new_booking_inserted ON bookings;
+ DROP TRIGGER IF EXISTS on_booking_approved ON bookings;
+ DROP TRIGGER IF EXISTS on_new_request_inserted ON requests;
+ DROP TRIGGER IF EXISTS on_new_request_comment_inserted ON request_comments;
+ ```
+
+2. Drop the trigger functions:
+ ```sql
+ DROP FUNCTION IF EXISTS public.handle_new_booking();
+ DROP FUNCTION IF EXISTS public.handle_booking_approval();
+ DROP FUNCTION IF EXISTS public.handle_new_request();
+ DROP FUNCTION IF EXISTS public.handle_new_request_comment();
+ ```
+
+3. Drop the helper functions:
+ ```sql
+ DROP FUNCTION IF EXISTS public.get_trigger_auth_secret();
+ DROP FUNCTION IF EXISTS public.create_webhook_signature();
+ ```
+
+4. Remove the email triggers SQL file:
+ ```bash
+ rm bookingsystem-dbtrigger/email_triggers.sql
+ ```
+
+## Error Handling
+
+The frontend hooks include comprehensive error handling:
+
+- If the edge function call fails, it shows an error message to the user
+- Database operations, emails, and calendar entries happen atomically
+- If any part fails, the entire operation is rolled back
+- Users get clear feedback about what went wrong
+
+## Testing
+
+### Test Edge Function Directly
+```typescript
+import { callEdgeFunction, createBookingPayload } from '@/utils/edgeFunctionUtils';
+
+const testPayload = createBookingPayload({
+ id: 'test-id',
+ title: 'Test Booking',
+ // ... other fields
+}, 'new_booking');
+
+const result = await callEdgeFunction(testPayload);
+console.log(result);
+```
+
+### Test in Development
+1. Create a booking/request through the UI
+2. Check browser console for edge function call logs
+3. Verify database records are created
+4. Verify emails are sent and calendar entries are created
+
+## Troubleshooting
+
+### Edge Function Not Called
+1. Check browser console for errors
+2. Verify Supabase client is properly authenticated
+3. Check edge function logs in Supabase dashboard
+
+### Database Operations Fail
+1. Check edge function logs for database errors
+2. Verify Supabase service role key is configured
+3. Check database permissions and RLS policies
+
+### Emails Not Sent
+1. Check edge function logs for email configuration errors
+2. Verify environment variables are set correctly
+3. Check if `EMAIL_DISABLED` is set to `true`
+
+### Calendar Entries Not Created
+1. Check edge function logs for calendar integration errors
+2. Verify Google Calendar credentials are configured
+3. Check Nostr integration settings
+
+## Future Enhancements
+
+1. **Add More Event Types** - Support for cancellation and completion notifications
+2. **Retry Logic** - Implement retry mechanism for failed operations
+3. **Queue System** - Use a queue for edge function calls to handle high load
+4. **Metrics** - Add monitoring and metrics for edge function calls
+5. **Transaction Support** - Implement proper database transactions for complex operations
\ No newline at end of file
diff --git a/package.json b/package.json
index 06e3b40..d038a4b 100644
--- a/package.json
+++ b/package.json
@@ -93,5 +93,6 @@
"typescript-eslint": "^8.32.1",
"vite": "^5.4.19"
},
- "prettier": {}
+ "prettier": {},
+ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}
diff --git a/public/locales/de/common.json b/public/locales/de/common.json
index 8a65998..ae14939 100644
--- a/public/locales/de/common.json
+++ b/public/locales/de/common.json
@@ -139,6 +139,7 @@
"loading": "Lädt...",
"saving": "Speichert...",
"submitting": "Reicht ein...",
+ "markAsCompleted": "Als abgeschlossen markieren",
"title": {
"label": "Titel",
"placeholder": "Geben Sie den Titel der Veranstaltung ein",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 71f11df..b365391 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -142,7 +142,8 @@
"submit": "Submit Booking Request",
"submitting": "Submitting...",
"clearDraft": "Clear Draft",
- "startNew": "Start New"
+ "startNew": "Start New",
+ "markAsCompleted": "Mark as Completed"
}
},
"profile": {
diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json
index 730d9a3..b87df88 100644
--- a/public/locales/fr/common.json
+++ b/public/locales/fr/common.json
@@ -142,7 +142,8 @@
"submit": "Soumettre la demande de réservation",
"submitting": "Soumission...",
"clearDraft": "Effacer le brouillon",
- "startNew": "Commencer nouveau"
+ "startNew": "Commencer nouveau",
+ "markAsCompleted": "Marquer comme terminé"
}
},
"profile": {
diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json
index c778897..192c58e 100644
--- a/public/locales/nl/common.json
+++ b/public/locales/nl/common.json
@@ -142,7 +142,8 @@
"submit": "Boekingsaanvraag indienen",
"submitting": "Indienen...",
"clearDraft": "Concept wissen",
- "startNew": "Nieuw beginnen"
+ "startNew": "Nieuw beginnen",
+ "markAsCompleted": "Markeren als voltooid"
}
},
"profile": {
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 5af09b7..27d43fe 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -14,6 +14,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { LogoutConfirmDialog } from "./LogoutConfirmDialog";
import { useLogoutDraftHandler } from "@/hooks/useLogoutDraftHandler";
import { useState } from "react";
+import { canUserApproveBookings } from "@/utils/bookingHelpers";
const Header = () => {
const { user, signOut, getDisplayName } = useAuth();
@@ -67,11 +68,13 @@ const Header = () => {
-
+ {canUserApproveBookings(user) && (
+
+ )}
{user ? (
diff --git a/src/context/AuthProvider.tsx b/src/context/AuthProvider.tsx
index d2600e4..c0888e7 100644
--- a/src/context/AuthProvider.tsx
+++ b/src/context/AuthProvider.tsx
@@ -1,7 +1,7 @@
import { supabase } from "@/integrations/supabase/client";
import { AuthContext } from "./AuthContext";
import { useEffect, useState } from "react";
-import { User } from "@/types";
+import { User, UserRole } from "@/types";
import { User as SupabaseUser } from "@supabase/supabase-js";
export function AuthProvider({ children }: { children: React.ReactNode }) {
@@ -9,18 +9,52 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const baseUrl = import.meta.env.VITE_DEPLOY_URL || window.location.origin
+ const fetchUserRoles = async (userId: string): Promise => {
+ try {
+ const { data, error } = await supabase
+ .from('user_roles')
+ .select('role')
+ .eq('user_id', userId);
+
+ if (error) {
+ console.error('Error fetching user roles:', error);
+ return [];
+ }
+
+ return data?.map(row => row.role as UserRole) || [];
+ } catch (error) {
+ console.error('Error fetching user roles:', error);
+ return [];
+ }
+ };
+
+ const supabaseUserToUser = async (supabaseUser: SupabaseUser | null): Promise => {
+ if (!supabaseUser) return null;
+
+ const roles = await fetchUserRoles(supabaseUser.id);
+
+ return {
+ id: supabaseUser.id,
+ email: supabaseUser.email || "",
+ name: supabaseUser.user_metadata.full_name || "",
+ roles,
+ };
+ };
+
useEffect(() => {
// Check active sessions and sets the user
- supabase.auth.getSession().then(({ data: { session } }) => {
- setUser(supabaseUserToUser(session?.user ?? null));
+ supabase.auth.getSession().then(async ({ data: { session } }) => {
+ const user = await supabaseUserToUser(session?.user ?? null);
+ setUser(user);
setLoading(false);
});
// Listen for changes on auth state (sign in, sign out, etc.)
const {
data: { subscription },
- } = supabase.auth.onAuthStateChange((_event, session) => {
- setUser(supabaseUserToUser(session?.user ?? null));
+ } = supabase.auth.onAuthStateChange(async (_event, session) => {
+ const user = await supabaseUserToUser(session?.user ?? null);
+ setUser(user);
setLoading(false);
});
@@ -56,12 +90,3 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
);
}
-
-const supabaseUserToUser = (user: SupabaseUser | null): User | null => {
- if (!user) return null;
- return {
- id: user.id,
- email: user.email || "",
- name: user.user_metadata.full_name || "",
- };
-};
diff --git a/src/context/RequestProvider.tsx b/src/context/RequestProvider.tsx
index 1517f68..fbdcee4 100644
--- a/src/context/RequestProvider.tsx
+++ b/src/context/RequestProvider.tsx
@@ -3,6 +3,7 @@ import { Request, User } from "@/types";
import { useAuth } from "./AuthContext";
import { useRequestOperations } from "@/hooks/useRequestOperations";
import { useRequestCommentOperations } from "@/hooks/useRequestCommentOperations";
+import { isAdmin } from "@/utils/adminUtils";
interface RequestProviderProps {
children: React.ReactNode;
@@ -70,11 +71,8 @@ export const RequestProvider: React.FC = ({ children }) =>
const canUserMarkAsCompleted = (request: Request, currentUser: User | null): boolean => {
if (!currentUser) return false;
- // Admin-only action - check if user has admin permissions
- // This could be based on email domain or other criteria
- const isAdmin = currentUser.email?.endsWith('@commonshub.brussels') ||
- currentUser.email?.endsWith('@qualiaworks.com');
- return isAdmin && request.status !== "completed" && request.status !== "cancelled";
+ // Admin-only action - check if user has admin permissions using RBAC
+ return isAdmin(currentUser) && request.status !== "completed" && request.status !== "cancelled";
};
const value = {
diff --git a/src/hooks/useApprovalOperations.ts b/src/hooks/useApprovalOperations.ts
index 700c288..def5ba4 100644
--- a/src/hooks/useApprovalOperations.ts
+++ b/src/hooks/useApprovalOperations.ts
@@ -2,6 +2,7 @@
import { toast } from "@/components/ui/toast-utils";
import { supabase } from "@/integrations/supabase/client";
import { Booking, User } from "@/types";
+import { callEdgeFunction, createBookingPayload } from "@/utils/edgeFunctionUtils";
export const useApprovalOperations = (
setBookings: React.Dispatch>
@@ -16,21 +17,38 @@ export const useApprovalOperations = (
}
try {
- const { error } = await supabase
+ // Get the current booking data
+ const { data: currentBooking, error: fetchError } = await supabase
.from("bookings")
- .update({
- status: "approved",
- approved_by_email: user.email,
- approved_at: new Date().toISOString(),
- })
- .eq("id", id);
-
- if (error) {
- console.error("Error approving booking:", error);
- toast.error("Failed to approve booking");
+ .select("*")
+ .eq("id", id)
+ .single();
+
+ if (fetchError || !currentBooking) {
+ console.error("Error fetching booking:", fetchError);
+ toast.error("Failed to fetch booking data");
return;
}
+ // Prepare the updated booking data to send to edge function
+ const updatedBookingRecord = {
+ ...currentBooking,
+ status: "approved",
+ approved_by_email: user.email,
+ };
+
+ // Call edge function to handle database update and notifications
+ const edgeFunctionResult = await callEdgeFunction(
+ createBookingPayload(updatedBookingRecord, 'confirmed_booking')
+ );
+
+ if (!edgeFunctionResult.success) {
+ console.error("Edge function call failed:", edgeFunctionResult.error);
+ toast.error("Failed to approve booking");
+ throw new Error(edgeFunctionResult.error || "Failed to approve booking");
+ }
+
+ // Update local state
setBookings((prevBookings) =>
prevBookings.map((booking) =>
booking.id === id
diff --git a/src/hooks/useCreateBooking.ts b/src/hooks/useCreateBooking.ts
index dfe1ac4..bf28013 100644
--- a/src/hooks/useCreateBooking.ts
+++ b/src/hooks/useCreateBooking.ts
@@ -5,6 +5,7 @@ import { Database } from "@/integrations/supabase/types";
import { Booking, BookingDatabaseFields, User } from "@/types";
import { v4 as uuidv4 } from "uuid";
import { useTranslation } from "react-i18next";
+import { callEdgeFunction, createBookingPayload } from "@/utils/edgeFunctionUtils";
export const useCreateBooking = (
setBookings: React.Dispatch>,
@@ -21,7 +22,8 @@ export const useCreateBooking = (
// Store room capacity as a string (no need to convert since it's already a string)
const roomCapacity = bookingData.room.capacity;
- const row: Database["public"]["Tables"]["bookings"]["Insert"] = {
+ // Prepare the booking data to send to edge function
+ const bookingRecord = {
id,
title: bookingData.title,
description: bookingData.description,
@@ -37,24 +39,27 @@ export const useCreateBooking = (
is_public_event: bookingData.isPublicEvent,
organizer: bookingData.organizer,
estimated_attendees: bookingData.estimatedAttendees,
- language: i18n.language, // Add current language
+ language: i18n.language,
price: bookingData.price,
currency: bookingData.currency,
- // Add the new fields
catering_options: bookingData.cateringOptions,
catering_comments: bookingData.cateringComments,
event_support_options: bookingData.eventSupportOptions,
membership_status: bookingData.membershipStatus,
};
+
+ // Call edge function to handle database insertion and notifications
+ const edgeFunctionResult = await callEdgeFunction(
+ createBookingPayload(bookingRecord, 'new_booking')
+ );
- const { error } = await supabase.from("bookings").insert(row);
-
- if (error) {
- console.error("Error creating booking:", error);
+ if (!edgeFunctionResult.success) {
+ console.error("Edge function call failed:", edgeFunctionResult.error);
toast.error("Failed to create booking");
- throw error;
+ throw new Error(edgeFunctionResult.error || "Failed to create booking");
}
+ // Update local state with the new booking
const { data: newBookings } = await supabase
.from("bookings")
.select("*")
diff --git a/src/hooks/useCreateRequest.ts b/src/hooks/useCreateRequest.ts
index 08ab407..e583924 100644
--- a/src/hooks/useCreateRequest.ts
+++ b/src/hooks/useCreateRequest.ts
@@ -4,6 +4,7 @@ import { Database } from "@/integrations/supabase/types";
import { Request, User } from "@/types";
import { v4 as uuidv4 } from "uuid";
import { useTranslation } from "react-i18next";
+import { callEdgeFunction, createRequestPayload } from "@/utils/edgeFunctionUtils";
export const useCreateRequest = (
setRequests: React.Dispatch>,
@@ -17,15 +18,16 @@ export const useCreateRequest = (
const id = uuidv4();
try {
- const row: Database["public"]["Tables"]["requests"]["Insert"] = {
+ // Prepare the request data to send to edge function
+ const requestRecord = {
id,
title: requestData.title,
description: requestData.description,
request_type: requestData.requestType,
priority: requestData.priority,
status: "pending",
- created_by_email: requestData.email, // Use the email from the form
- created_by_name: requestData.name, // Use the name from the form
+ created_by_email: requestData.email,
+ created_by_name: requestData.name,
email: requestData.email,
name: requestData.name,
phone: requestData.phone,
@@ -33,17 +35,21 @@ export const useCreateRequest = (
expected_completion_date: requestData.expectedCompletionDate,
additional_details: requestData.additionalDetails,
attachments: requestData.attachments,
- language: i18n.language, // Add current language
+ language: i18n.language,
};
+
+ // Call edge function to handle database insertion and notifications
+ const edgeFunctionResult = await callEdgeFunction(
+ createRequestPayload(requestRecord, 'new_request')
+ );
- const { error } = await supabase.from("requests").insert(row);
-
- if (error) {
- console.error("Error creating request:", error);
+ if (!edgeFunctionResult.success) {
+ console.error("Edge function call failed:", edgeFunctionResult.error);
toast.error("Failed to create request");
- throw error;
+ throw new Error(edgeFunctionResult.error || "Failed to create request");
}
+ // Update local state with the new request
const { data: newRequests } = await supabase
.from("requests")
.select("*")
@@ -87,15 +93,16 @@ export const useCreateUnauthenticatedRequest = () => {
const id = uuidv4();
try {
- const row: Database["public"]["Tables"]["requests"]["Insert"] = {
+ // Prepare the request data to send to edge function
+ const requestRecord = {
id,
title: requestData.title,
description: requestData.description,
request_type: requestData.requestType,
priority: requestData.priority,
status: "pending",
- created_by_email: requestData.email, // Use the email from the form
- created_by_name: requestData.name, // Use the name from the form
+ created_by_email: requestData.email,
+ created_by_name: requestData.name,
email: requestData.email,
name: requestData.name,
phone: requestData.phone,
@@ -103,14 +110,17 @@ export const useCreateUnauthenticatedRequest = () => {
expected_completion_date: requestData.expectedCompletionDate,
additional_details: requestData.additionalDetails,
attachments: requestData.attachments,
- language: i18n.language, // Add current language
+ language: i18n.language,
};
+
+ // Call edge function to handle database insertion and notifications
+ const edgeFunctionResult = await callEdgeFunction(
+ createRequestPayload(requestRecord, 'new_request')
+ );
- const { error } = await supabase.from("requests").insert(row);
-
- if (error) {
- console.error("Error creating unauthenticated request:", error);
- throw error;
+ if (!edgeFunctionResult.success) {
+ console.error("Edge function call failed:", edgeFunctionResult.error);
+ throw new Error(edgeFunctionResult.error || "Failed to create request");
}
return id;
diff --git a/src/hooks/useRequestCommentOperations.ts b/src/hooks/useRequestCommentOperations.ts
index 0cf3bea..72b6009 100644
--- a/src/hooks/useRequestCommentOperations.ts
+++ b/src/hooks/useRequestCommentOperations.ts
@@ -2,6 +2,7 @@ import { toast } from "@/components/ui/toast-utils";
import { supabase } from "@/integrations/supabase/client";
import { Request, RequestComment } from "@/types";
import { v4 as uuidv4 } from "uuid";
+import { callEdgeFunction, createRequestCommentPayload } from "@/utils/edgeFunctionUtils";
export const useRequestCommentOperations = (
setRequests: React.Dispatch>,
@@ -32,6 +33,27 @@ export const useRequestCommentOperations = (
throw error;
}
+ // Call edge function directly after successful database insert
+ const commentRecord = {
+ id: data.id,
+ request_id: requestId,
+ content,
+ created_at: data.created_at,
+ created_by_email: email,
+ created_by_name: name,
+ status: "published",
+ };
+
+ const edgeFunctionResult = await callEdgeFunction(
+ createRequestCommentPayload(commentRecord, 'new_request_comment')
+ );
+
+ if (!edgeFunctionResult.success) {
+ console.warn("Edge function call failed:", edgeFunctionResult.error);
+ // Don't throw error here as the comment was created successfully
+ // Just log the warning
+ }
+
const newComment: RequestComment = {
id: data.id,
requestId,
diff --git a/src/hooks/useUserRoles.ts b/src/hooks/useUserRoles.ts
new file mode 100644
index 0000000..95068a7
--- /dev/null
+++ b/src/hooks/useUserRoles.ts
@@ -0,0 +1,71 @@
+import { useEffect, useState } from 'react';
+import { supabase } from '@/integrations/supabase/client';
+import { User } from '@/types';
+
+export type UserRole = 'admin' | 'moderator' | 'support' | 'user';
+
+export interface UserWithRoles extends User {
+ roles: UserRole[];
+}
+
+export const useUserRoles = (user: User | null) => {
+ const [userWithRoles, setUserWithRoles] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ const fetchUserRoles = async () => {
+ if (!user) {
+ setUserWithRoles(null);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const { data, error } = await supabase
+ .from('user_roles')
+ .select('role')
+ .eq('user_id', user.id);
+
+ if (error) {
+ console.error('Error fetching user roles:', error);
+ setUserWithRoles({ ...user, roles: [] });
+ } else {
+ const roles = data?.map(row => row.role as UserRole) || [];
+ setUserWithRoles({ ...user, roles });
+ }
+ } catch (error) {
+ console.error('Error fetching user roles:', error);
+ setUserWithRoles({ ...user, roles: [] });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchUserRoles();
+ }, [user]);
+
+ const hasRole = (role: UserRole): boolean => {
+ return userWithRoles?.roles.includes(role) || false;
+ };
+
+ const hasPermission = (permission: string): boolean => {
+ // For now, we'll implement a simple permission check
+ // In a full implementation, you'd query the role_permissions table
+ if (hasRole('admin')) return true;
+ if (hasRole('moderator') && permission.startsWith('requests.') || permission.startsWith('comments.')) return true;
+ if (hasRole('support') && permission.startsWith('requests.')) return true;
+ return false;
+ };
+
+ const isAdmin = (): boolean => {
+ return hasRole('admin');
+ };
+
+ return {
+ userWithRoles,
+ loading,
+ hasRole,
+ hasPermission,
+ isAdmin,
+ };
+};
\ No newline at end of file
diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts
index 67486a7..4bd525f 100644
--- a/src/integrations/supabase/client.ts
+++ b/src/integrations/supabase/client.ts
@@ -16,4 +16,16 @@ if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
export const supabase = createClient(
SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY,
+ {
+ auth: {
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: true
+ },
+ global: {
+ headers: {
+ 'X-Client-Info': 'supabase-js/2.x.x'
+ }
+ }
+ }
);
diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts
index 0e9d55d..cc2ed98 100644
--- a/src/integrations/supabase/types.ts
+++ b/src/integrations/supabase/types.ts
@@ -286,6 +286,59 @@ export type Database = {
}
Relationships: []
}
+ user_roles: {
+ Row: {
+ id: number
+ user_id: string
+ role: string
+ created_at: string
+ created_by: string | null
+ }
+ Insert: {
+ id?: number
+ user_id: string
+ role: string
+ created_at?: string
+ created_by?: string | null
+ }
+ Update: {
+ id?: number
+ user_id?: string
+ role?: string
+ created_at?: string
+ created_by?: string | null
+ }
+ Relationships: [
+ {
+ foreignKeyName: "user_roles_user_id_fkey"
+ columns: ["user_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ role_permissions: {
+ Row: {
+ id: number
+ role: string
+ permission: string
+ created_at: string
+ }
+ Insert: {
+ id?: number
+ role: string
+ permission: string
+ created_at?: string
+ }
+ Update: {
+ id?: number
+ role?: string
+ permission?: string
+ created_at?: string
+ }
+ Relationships: []
+ }
}
Views: {
[_ in never]: never
diff --git a/src/types/index.ts b/src/types/index.ts
index 18c1336..6e5425b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -16,11 +16,14 @@ export interface RoomSetupOption {
description: string;
}
+export type UserRole = 'admin' | 'moderator' | 'support' | 'user';
+
export interface User {
id: string;
email: string;
name?: string;
profileId?: string;
+ roles?: UserRole[];
}
export interface Booking {
diff --git a/src/utils/adminUtils.ts b/src/utils/adminUtils.ts
new file mode 100644
index 0000000..9272964
--- /dev/null
+++ b/src/utils/adminUtils.ts
@@ -0,0 +1,30 @@
+import { User } from "@/types";
+
+/**
+ * Check if a user has admin privileges using the RBAC system
+ * Falls back to email domain check for backward compatibility
+ */
+export const isAdmin = (user: User | null): boolean => {
+ if (!user) return false;
+
+ // Check if user has admin role using RBAC system
+ if (user.roles?.includes('admin')) return true;
+
+ return false;
+};
+
+/**
+ * Check if a user has a specific role
+ */
+export const hasRole = (user: User | null, role: string): boolean => {
+ if (!user) return false;
+ return user.roles?.includes(role as any) || false;
+};
+
+/**
+ * Check if a user has any of the specified roles
+ */
+export const hasAnyRole = (user: User | null, roles: string[]): boolean => {
+ if (!user) return false;
+ return user.roles?.some(role => roles.includes(role)) || false;
+};
\ No newline at end of file
diff --git a/src/utils/bookingHelpers.ts b/src/utils/bookingHelpers.ts
index 3e533d2..75d8969 100644
--- a/src/utils/bookingHelpers.ts
+++ b/src/utils/bookingHelpers.ts
@@ -1,13 +1,9 @@
import { Booking, User } from "@/types";
+import { isAdmin } from "./adminUtils";
// Function to check if a user can approve bookings
export const canUserApproveBookings = (user: User | null): boolean => {
- if (!user) return false;
-
- // Allow users with commonshub.brussels or qualiaworks.com email domain
- if (user.email.endsWith("@commonshub.brussels") || user.email.endsWith("@qualiaworks.com")) return true;
-
- return false;
+ return isAdmin(user);
};
// Function to check if a user can cancel a specific booking
diff --git a/src/utils/edgeFunctionUtils.ts b/src/utils/edgeFunctionUtils.ts
new file mode 100644
index 0000000..ec30778
--- /dev/null
+++ b/src/utils/edgeFunctionUtils.ts
@@ -0,0 +1,115 @@
+import { supabase } from "@/integrations/supabase/client";
+
+// Edge function URL
+const EDGE_FUNCTION_NAME = 'requests';
+// Type definitions for edge function payloads
+interface EdgeFunctionPayload {
+ record: Record;
+ type: string;
+}
+
+// Function to call the edge function using Supabase's built-in function invocation
+export async function callEdgeFunction(payload: EdgeFunctionPayload): Promise<{ success: boolean; message?: string; error?: string }> {
+ try {
+ // Use Supabase's built-in function invocation which handles authentication
+ const { data, error } = await supabase.functions.invoke(EDGE_FUNCTION_NAME, {
+ method: 'POST',
+ body: payload,
+ });
+
+ if (error) {
+ console.error('Edge function error:', error);
+ return { success: false, error: error.message || 'Failed to call edge function' };
+ }
+
+ return { success: true, message: data?.message || 'Success' };
+ } catch (error) {
+ console.error('Error calling edge function:', error);
+ return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
+ }
+}
+
+// Helper function to create booking payload
+export function createBookingPayload(booking: Record, type: 'new_booking' | 'confirmed_booking'): EdgeFunctionPayload {
+ return {
+ record: {
+ id: booking.id,
+ title: booking.title,
+ description: booking.description,
+ room_id: booking.room_id,
+ room_name: booking.room_name,
+ room_capacity: booking.room_capacity,
+ start_time: booking.start_time,
+ end_time: booking.end_time,
+ status: booking.status,
+ created_by_email: booking.created_by_email,
+ created_by_name: booking.created_by_name,
+ created_at: booking.created_at,
+ approved_by_email: booking.approved_by_email,
+ approved_at: booking.approved_at,
+ additional_comments: booking.additional_comments,
+ is_public_event: booking.is_public_event,
+ cancelled_at: booking.cancelled_at,
+ cancelled_by_email: booking.cancelled_by_email,
+ organizer: booking.organizer,
+ estimated_attendees: booking.estimated_attendees,
+ luma_event_url: booking.luma_event_url,
+ calendar_url: booking.calendar_url,
+ public_uri: booking.public_uri,
+ language: booking.language,
+ price: booking.price,
+ currency: booking.currency,
+ catering_options: booking.catering_options,
+ catering_comments: booking.catering_comments,
+ event_support_options: booking.event_support_options,
+ membership_status: booking.membership_status
+ },
+ type
+ };
+}
+
+// Helper function to create request payload
+export function createRequestPayload(request: Record, type: 'new_request'): EdgeFunctionPayload {
+ return {
+ record: {
+ id: request.id,
+ title: request.title,
+ description: request.description,
+ request_type: request.request_type,
+ priority: request.priority,
+ status: request.status,
+ created_by_email: request.created_by_email,
+ created_by_name: request.created_by_name,
+ created_at: request.created_at,
+ email: request.email,
+ name: request.name,
+ phone: request.phone,
+ organization: request.organization,
+ expected_completion_date: request.expected_completion_date,
+ additional_details: request.additional_details,
+ attachments: request.attachments,
+ language: request.language,
+ completed_at: request.completed_at,
+ completed_by_email: request.completed_by_email,
+ cancelled_at: request.cancelled_at,
+ cancelled_by_email: request.cancelled_by_email
+ },
+ type
+ };
+}
+
+// Helper function to create request comment payload
+export function createRequestCommentPayload(comment: Record, type: 'new_request_comment'): EdgeFunctionPayload {
+ return {
+ record: {
+ comment_id: comment.id,
+ request_id: comment.request_id,
+ content: comment.content,
+ created_at: comment.created_at,
+ created_by_email: comment.created_by_email,
+ created_by_name: comment.created_by_name,
+ status: comment.status
+ },
+ type
+ };
+}
\ No newline at end of file
diff --git a/supabase/config.toml b/supabase/config.toml
index 1987c89..9bdba01 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -1 +1,331 @@
-project_id = "sokfvqtgpbeybjifaywh"
\ No newline at end of file
+project_id = "sokfvqtgpbeybjifaywh"
+[functions.requests]
+enabled = true
+verify_jwt = true
+import_map = "./functions/edge-requests/deno.json"
+# Uncomment to specify a custom file path to the entrypoint.
+# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
+entrypoint = "./functions/edge-requests/src/index.ts"
+# Specifies static files to be bundled with the function. Supports glob patterns.
+# For example, if you want to serve static HTML pages in your function:
+# static_files = [ "./functions/requests/*.html" ]
+
+
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files that describe your database.
+# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "https://booking.commonshub.brussels"
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://booking.commonshub.brussels", "http://localhost:3000", "http://localhost:8080", "https://*.lovable.app/", "http://localhost:8081", "http://localhost:8080/*"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = true
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1m0s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 86400
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ .Code }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = true
+verify_enabled = true
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ .Code }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth redirectUrl.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+[edge_runtime]
+enabled = true
+# Configure one of the supported request policies: `oneshot`, `per_worker`.
+# Use `oneshot` for hot reload, or `per_worker` for load testing.
+policy = "oneshot"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 1
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
diff --git a/supabase/functions/edge-requests/.gitignore b/supabase/functions/edge-requests/.gitignore
new file mode 100644
index 0000000..ad9264f
--- /dev/null
+++ b/supabase/functions/edge-requests/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/supabase/functions/edge-requests/.npmrc b/supabase/functions/edge-requests/.npmrc
new file mode 100644
index 0000000..48c6388
--- /dev/null
+++ b/supabase/functions/edge-requests/.npmrc
@@ -0,0 +1,3 @@
+# Configuration for private npm package dependencies
+# For more information on using private registries with Edge Functions, see:
+# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
diff --git a/supabase/functions/edge-requests/deno.json b/supabase/functions/edge-requests/deno.json
new file mode 100644
index 0000000..1de7a58
--- /dev/null
+++ b/supabase/functions/edge-requests/deno.json
@@ -0,0 +1,8 @@
+{
+ "imports": {
+ "npm:googleapis": "npm:googleapis@^137.0.0",
+ "jsr:@std/dotenv": "jsr:@std/dotenv",
+ "npm:@supabase/supabase-js@2": "npm:@supabase/supabase-js@2",
+ "nostr-tools": "npm:nostr-tools@^2.15.1"
+ }
+}
diff --git a/supabase/functions/edge-requests/deno.lock b/supabase/functions/edge-requests/deno.lock
new file mode 100644
index 0000000..4f0bfc5
--- /dev/null
+++ b/supabase/functions/edge-requests/deno.lock
@@ -0,0 +1,577 @@
+{
+ "version": "5",
+ "specifiers": {
+ "jsr:@std/assert@*": "1.0.13",
+ "jsr:@std/dotenv@*": "0.225.5",
+ "jsr:@std/internal@^1.0.6": "1.0.10",
+ "jsr:@supabase/functions-js@*": "2.4.5",
+ "npm:@supabase/supabase-js@2": "2.52.0",
+ "npm:@types/node@*": "22.15.15",
+ "npm:googleapis@137": "137.1.0",
+ "npm:nostr-tools@^2.15.1": "2.15.1",
+ "npm:openai@^4.52.5": "4.104.0"
+ },
+ "jsr": {
+ "@std/assert@1.0.13": {
+ "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
+ "dependencies": [
+ "jsr:@std/internal"
+ ]
+ },
+ "@std/dotenv@0.225.5": {
+ "integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f"
+ },
+ "@std/internal@1.0.10": {
+ "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7"
+ },
+ "@supabase/functions-js@2.4.5": {
+ "integrity": "ead2df253fe0c3b0b682c5f25076978ab62c10afff4d04222451a90ba512c394",
+ "dependencies": [
+ "npm:openai"
+ ]
+ }
+ },
+ "npm": {
+ "@noble/ciphers@0.5.3": {
+ "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
+ },
+ "@noble/curves@1.1.0": {
+ "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
+ "dependencies": [
+ "@noble/hashes@1.3.1"
+ ]
+ },
+ "@noble/curves@1.2.0": {
+ "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+ "dependencies": [
+ "@noble/hashes@1.3.2"
+ ]
+ },
+ "@noble/hashes@1.3.1": {
+ "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="
+ },
+ "@noble/hashes@1.3.2": {
+ "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="
+ },
+ "@scure/base@1.1.1": {
+ "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="
+ },
+ "@scure/bip32@1.3.1": {
+ "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
+ "dependencies": [
+ "@noble/curves@1.1.0",
+ "@noble/hashes@1.3.2",
+ "@scure/base"
+ ]
+ },
+ "@scure/bip39@1.2.1": {
+ "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
+ "dependencies": [
+ "@noble/hashes@1.3.2",
+ "@scure/base"
+ ]
+ },
+ "@supabase/auth-js@2.71.1": {
+ "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
+ "dependencies": [
+ "@supabase/node-fetch"
+ ]
+ },
+ "@supabase/functions-js@2.4.5": {
+ "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
+ "dependencies": [
+ "@supabase/node-fetch"
+ ]
+ },
+ "@supabase/node-fetch@2.6.15": {
+ "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
+ "dependencies": [
+ "whatwg-url"
+ ]
+ },
+ "@supabase/postgrest-js@1.19.4": {
+ "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
+ "dependencies": [
+ "@supabase/node-fetch"
+ ]
+ },
+ "@supabase/realtime-js@2.11.15_ws@8.18.3": {
+ "integrity": "sha512-HQKRnwAqdVqJW/P9TjKVK+/ETpW4yQ8tyDPPtRMKOH4Uh3vQD74vmj353CYs8+YwVBKubeUOOEpI9CT8mT4obw==",
+ "dependencies": [
+ "@supabase/node-fetch",
+ "@types/phoenix",
+ "@types/ws",
+ "isows",
+ "ws"
+ ]
+ },
+ "@supabase/storage-js@2.7.1": {
+ "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
+ "dependencies": [
+ "@supabase/node-fetch"
+ ]
+ },
+ "@supabase/supabase-js@2.52.0": {
+ "integrity": "sha512-jbs3CV1f2+ge7sgBeEduboT9v/uGjF22v0yWi/5/XFn5tbM8MfWRccsMtsDwAwu24XK8H6wt2LJDiNnZLtx/bg==",
+ "dependencies": [
+ "@supabase/auth-js",
+ "@supabase/functions-js",
+ "@supabase/node-fetch",
+ "@supabase/postgrest-js",
+ "@supabase/realtime-js",
+ "@supabase/storage-js"
+ ]
+ },
+ "@types/node-fetch@2.6.12": {
+ "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
+ "dependencies": [
+ "@types/node@22.15.15",
+ "form-data"
+ ]
+ },
+ "@types/node@18.19.120": {
+ "integrity": "sha512-WtCGHFXnVI8WHLxDAt5TbnCM4eSE+nI0QN2NJtwzcgMhht2eNz6V9evJrk+lwC8bCY8OWV5Ym8Jz7ZEyGnKnMA==",
+ "dependencies": [
+ "undici-types@5.26.5"
+ ]
+ },
+ "@types/node@22.15.15": {
+ "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
+ "dependencies": [
+ "undici-types@6.21.0"
+ ]
+ },
+ "@types/phoenix@1.6.6": {
+ "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="
+ },
+ "@types/ws@8.18.1": {
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dependencies": [
+ "@types/node@22.15.15"
+ ]
+ },
+ "abort-controller@3.0.0": {
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": [
+ "event-target-shim"
+ ]
+ },
+ "agent-base@7.1.4": {
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
+ },
+ "agentkeepalive@4.6.0": {
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "dependencies": [
+ "humanize-ms"
+ ]
+ },
+ "asynckit@0.4.0": {
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "base64-js@1.5.1": {
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
+ },
+ "bignumber.js@9.3.1": {
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
+ },
+ "buffer-equal-constant-time@1.0.1": {
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
+ },
+ "call-bind-apply-helpers@1.0.2": {
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": [
+ "es-errors",
+ "function-bind"
+ ]
+ },
+ "call-bound@1.0.4": {
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": [
+ "call-bind-apply-helpers",
+ "get-intrinsic"
+ ]
+ },
+ "combined-stream@1.0.8": {
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": [
+ "delayed-stream"
+ ]
+ },
+ "debug@4.4.1": {
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "dependencies": [
+ "ms"
+ ]
+ },
+ "delayed-stream@1.0.0": {
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+ },
+ "dunder-proto@1.0.1": {
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": [
+ "call-bind-apply-helpers",
+ "es-errors",
+ "gopd"
+ ]
+ },
+ "ecdsa-sig-formatter@1.0.11": {
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "dependencies": [
+ "safe-buffer"
+ ]
+ },
+ "es-define-property@1.0.1": {
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+ },
+ "es-errors@1.3.0": {
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+ },
+ "es-object-atoms@1.1.1": {
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": [
+ "es-errors"
+ ]
+ },
+ "es-set-tostringtag@2.1.0": {
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dependencies": [
+ "es-errors",
+ "get-intrinsic",
+ "has-tostringtag",
+ "hasown"
+ ]
+ },
+ "event-target-shim@5.0.1": {
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+ },
+ "extend@3.0.2": {
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+ },
+ "form-data-encoder@1.7.2": {
+ "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+ },
+ "form-data@4.0.4": {
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "dependencies": [
+ "asynckit",
+ "combined-stream",
+ "es-set-tostringtag",
+ "hasown",
+ "mime-types"
+ ]
+ },
+ "formdata-node@4.4.1": {
+ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+ "dependencies": [
+ "node-domexception",
+ "web-streams-polyfill"
+ ]
+ },
+ "function-bind@1.1.2": {
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+ },
+ "gaxios@6.7.1": {
+ "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
+ "dependencies": [
+ "extend",
+ "https-proxy-agent",
+ "is-stream",
+ "node-fetch",
+ "uuid"
+ ]
+ },
+ "gcp-metadata@6.1.1": {
+ "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
+ "dependencies": [
+ "gaxios",
+ "google-logging-utils",
+ "json-bigint"
+ ]
+ },
+ "get-intrinsic@1.3.0": {
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": [
+ "call-bind-apply-helpers",
+ "es-define-property",
+ "es-errors",
+ "es-object-atoms",
+ "function-bind",
+ "get-proto",
+ "gopd",
+ "has-symbols",
+ "hasown",
+ "math-intrinsics"
+ ]
+ },
+ "get-proto@1.0.1": {
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": [
+ "dunder-proto",
+ "es-object-atoms"
+ ]
+ },
+ "google-auth-library@9.15.1": {
+ "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
+ "dependencies": [
+ "base64-js",
+ "ecdsa-sig-formatter",
+ "gaxios",
+ "gcp-metadata",
+ "gtoken",
+ "jws"
+ ]
+ },
+ "google-logging-utils@0.0.2": {
+ "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
+ },
+ "googleapis-common@7.2.0": {
+ "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==",
+ "dependencies": [
+ "extend",
+ "gaxios",
+ "google-auth-library",
+ "qs",
+ "url-template",
+ "uuid"
+ ]
+ },
+ "googleapis@137.1.0": {
+ "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==",
+ "dependencies": [
+ "google-auth-library",
+ "googleapis-common"
+ ]
+ },
+ "gopd@1.2.0": {
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+ },
+ "gtoken@7.1.0": {
+ "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
+ "dependencies": [
+ "gaxios",
+ "jws"
+ ]
+ },
+ "has-symbols@1.1.0": {
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+ },
+ "has-tostringtag@1.0.2": {
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dependencies": [
+ "has-symbols"
+ ]
+ },
+ "hasown@2.0.2": {
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": [
+ "function-bind"
+ ]
+ },
+ "https-proxy-agent@7.0.6": {
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dependencies": [
+ "agent-base",
+ "debug"
+ ]
+ },
+ "humanize-ms@1.2.1": {
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "dependencies": [
+ "ms"
+ ]
+ },
+ "is-stream@2.0.1": {
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
+ },
+ "isows@1.0.7_ws@8.18.3": {
+ "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
+ "dependencies": [
+ "ws"
+ ]
+ },
+ "json-bigint@1.0.0": {
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "dependencies": [
+ "bignumber.js"
+ ]
+ },
+ "jwa@2.0.1": {
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "dependencies": [
+ "buffer-equal-constant-time",
+ "ecdsa-sig-formatter",
+ "safe-buffer"
+ ]
+ },
+ "jws@4.0.0": {
+ "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "dependencies": [
+ "jwa",
+ "safe-buffer"
+ ]
+ },
+ "math-intrinsics@1.1.0": {
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+ },
+ "mime-db@1.52.0": {
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+ },
+ "mime-types@2.1.35": {
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": [
+ "mime-db"
+ ]
+ },
+ "ms@2.1.3": {
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node-domexception@1.0.0": {
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": true
+ },
+ "node-fetch@2.7.0": {
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "dependencies": [
+ "whatwg-url"
+ ]
+ },
+ "nostr-tools@2.15.1": {
+ "integrity": "sha512-LpetHDR9ltnkpJDkva/SONgyKBbsoV+5yLB8DWc0/U3lCWGtoWJw6Nbc2vR2Ai67RIQYrBQeZLyMlhwVZRK/9A==",
+ "dependencies": [
+ "@noble/ciphers",
+ "@noble/curves@1.2.0",
+ "@noble/hashes@1.3.1",
+ "@scure/base",
+ "@scure/bip32",
+ "@scure/bip39",
+ "nostr-wasm"
+ ]
+ },
+ "nostr-wasm@0.1.0": {
+ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="
+ },
+ "object-inspect@1.13.4": {
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
+ },
+ "openai@4.104.0": {
+ "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
+ "dependencies": [
+ "@types/node@18.19.120",
+ "@types/node-fetch",
+ "abort-controller",
+ "agentkeepalive",
+ "form-data-encoder",
+ "formdata-node",
+ "node-fetch"
+ ],
+ "bin": true
+ },
+ "qs@6.14.0": {
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "dependencies": [
+ "side-channel"
+ ]
+ },
+ "safe-buffer@5.2.1": {
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
+ },
+ "side-channel-list@1.0.0": {
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": [
+ "es-errors",
+ "object-inspect"
+ ]
+ },
+ "side-channel-map@1.0.1": {
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": [
+ "call-bound",
+ "es-errors",
+ "get-intrinsic",
+ "object-inspect"
+ ]
+ },
+ "side-channel-weakmap@1.0.2": {
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": [
+ "call-bound",
+ "es-errors",
+ "get-intrinsic",
+ "object-inspect",
+ "side-channel-map"
+ ]
+ },
+ "side-channel@1.1.0": {
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": [
+ "es-errors",
+ "object-inspect",
+ "side-channel-list",
+ "side-channel-map",
+ "side-channel-weakmap"
+ ]
+ },
+ "tr46@0.0.3": {
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
+ },
+ "undici-types@5.26.5": {
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+ },
+ "undici-types@6.21.0": {
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+ },
+ "url-template@2.0.8": {
+ "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="
+ },
+ "uuid@9.0.1": {
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "bin": true
+ },
+ "web-streams-polyfill@4.0.0-beta.3": {
+ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
+ },
+ "webidl-conversions@3.0.1": {
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
+ },
+ "whatwg-url@5.0.0": {
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dependencies": [
+ "tr46",
+ "webidl-conversions"
+ ]
+ },
+ "ws@8.18.3": {
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
+ }
+ },
+ "redirects": {
+ "https://deno.land/x/denomailer/mod.ts": "https://deno.land/x/denomailer@1.6.0/mod.ts"
+ },
+ "remote": {
+ "https://deno.land/std@0.173.0/encoding/base64.ts": "7de04c2f8aeeb41453b09b186480be90f2ff357613b988e99fabb91d2eeceba1",
+ "https://deno.land/x/denomailer@1.6.0/client/basic/QUE.ts": "5af1dfcc5814bf4542f098908ac1fdd8a1a1c2b1597138a121c95eaa791315d0",
+ "https://deno.land/x/denomailer@1.6.0/client/basic/client.ts": "462e4db45ae218647812ceae720d55ea33e0e928f9138fee9da5913cbb1e20f9",
+ "https://deno.land/x/denomailer@1.6.0/client/basic/connection.ts": "68de68d7551d8303629905c2b7581cb09b45646e530ce93a5786ca1aba61055c",
+ "https://deno.land/x/denomailer@1.6.0/client/basic/transforms.ts": "e630a23d24e9b397e231ae8796c0a0080770ac6f5ab9bffc105d3717706e62c9",
+ "https://deno.land/x/denomailer@1.6.0/client/mod.ts": "8ec4c25d9586f83f8629768311a077eaf03b1490dcb872030ba2e27fadb674d8",
+ "https://deno.land/x/denomailer@1.6.0/client/pool.ts": "0466e69ca8959aa85501cc6b30d7f5fd8e43b0a6ac88ecc60dab71081e801bae",
+ "https://deno.land/x/denomailer@1.6.0/client/worker/worker.ts": "a4c3a3e2e1fde0967817ece7c345a565eb44a7312acf8d46ce620d4ff4443b31",
+ "https://deno.land/x/denomailer@1.6.0/config/client.ts": "302f5c18fbb5531b5615613084b86d44120acc210e072f4135e431fa27fc4526",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/attachments.ts": "1f357bddc9d5e813c3f647498db81a165a1a8a7163116c58dc10cf01427fd81e",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/content.ts": "3925d4c3baaabed4e08933159d34b1450e6426b35a5bc323a0666780bef20192",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/email.ts": "bb0ca104bf9cb54af6613a04b3f8cb05290f4fb012e7113f1d050cab10226a7c",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/encoding.ts": "0bc5983ada3b902333925cdca225f8ea5e28fffbc2f1bd2b0ccb9a423f6f7fcc",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/headers.ts": "ce94874beb5a1a7248b5b91bf1ae3b3aed2d4c0541f3f448f2bbfad6c8f570ee",
+ "https://deno.land/x/denomailer@1.6.0/config/mail/mod.ts": "a7fafa3386a45a585d7983d816b09ddc28b2f2b84097a614f1e38685b1f62868",
+ "https://deno.land/x/denomailer@1.6.0/deps.ts": "12bef188bb2a490fedc82ac1889f3d438e8a15887c423b045fee532b31a43102",
+ "https://deno.land/x/denomailer@1.6.0/mod.ts": "71a197dff098194ab53691abd3c9d22a276ef04e1382eb85f5632dbcb5a83bf3"
+ },
+ "workspace": {
+ "dependencies": [
+ "jsr:@std/dotenv@*",
+ "npm:@supabase/supabase-js@2",
+ "npm:googleapis@137",
+ "npm:nostr-tools@^2.15.1"
+ ]
+ }
+}
diff --git a/supabase/functions/edge-requests/src/handleCalendar.ts b/supabase/functions/edge-requests/src/handleCalendar.ts
new file mode 100644
index 0000000..031f387
--- /dev/null
+++ b/supabase/functions/edge-requests/src/handleCalendar.ts
@@ -0,0 +1,100 @@
+import { finalizeEvent, SimplePool, verifyEvent } from "nostr-tools";
+import { CalendarTimeBasedTemplateEvent } from "./lib/nip-52.ts";
+import { getCommunityATag } from "./lib/nip-72.ts";
+import { GoogleCalendarService, CalendarEvent } from "./lib/google-calendar.ts";
+import { hexToBytes } from "npm:nostr-tools/utils";
+
+
+enum BookingEventType {
+ NEW_BOOKING = "new_booking",
+ CONFIRMED_BOOKING = "confirmed_booking",
+ NEW_REQUEST = "new_request",
+ NEW_REQUEST_COMMENT = "new_request_comment",
+}
+
+export const handleCalendarEntry = async (record: any, type: BookingEventType) => {
+ const pool = new SimplePool();
+ const relays = ["wss://relay.chorus.community"];
+ const secretKey = Deno.env.get("NOSTR_SECRET_KEY");
+ const community_id = Deno.env.get("NOSTR_COMMUNITY_ID");
+ const community_identifier = Deno.env.get("NOSTR_COMMUNITY_IDENTIFIER");
+ const googleCalendarId = Deno.env.get("GOOGLE_CALENDAR_ID") || "primary";
+ if (!secretKey) {
+ return { success: false, error: "NOSTR_SECRET_KEY environment variable is not set" };
+ }
+
+ if (!community_id) {
+ return { success: false, error: "NOSTR_COMMUNITY_ID environment variable is not set" };
+ }
+
+ if (!community_identifier) {
+ return { success: false, error: "NOSTR_COMMUNITY_IDENTIFIER environment variable is not set" };
+ }
+
+ // Initialize Google Calendar service
+ const googleCalendar = new GoogleCalendarService();
+
+ if (type === BookingEventType.NEW_BOOKING) {
+ // Create a Google Calendar entry
+ try {
+ const calendarEvent: CalendarEvent = {
+ title: record.title,
+ description:
+ record.description ||
+ `New booking at Commons Hub Brussels - ${record.room_name}`,
+ startTime: new Date(record.start_time).toISOString(),
+ endTime: new Date(record.end_time).toISOString(),
+ location: `Commons Hub Brussels - ${record.room_name}`,
+ attendees: [record.created_by_email, "mushroom@gmail.com"],
+ };
+
+ const createdEvent = await googleCalendar.createEvent(
+ googleCalendarId,
+ calendarEvent
+ );
+ console.log("Google Calendar event created:", createdEvent.htmlLink);
+
+ // Optionally store the Google Calendar event ID in your database
+ // for future updates/deletions
+ record.google_calendar_event_id = createdEvent.id;
+ } catch (error) {
+ console.error("Failed to create Google Calendar event:", error);
+ // Don't fail the entire process if calendar creation fails
+ }
+ } else if (type === BookingEventType.CONFIRMED_BOOKING) {
+ // Create Nostr calendar entry
+ const calendarEvent: CalendarTimeBasedTemplateEvent = {
+ kind: 31923,
+ tags: [
+ ["a", getCommunityATag(community_id, community_identifier)],
+ ["d", Math.random().toString(36).substring(7)], // Random identifier
+ ["title", record.title],
+ ["start", dateToTimestamp(record.start_time)],
+ ["end", dateToTimestamp(record.end_time)],
+ ["location", "Commons Hub Brussels"],
+ ["location", record.room_name],
+ ],
+ content: record.description || "",
+ created_at: Math.floor(Date.now() / 1000),
+ };
+ const sk = hexToBytes(secretKey);
+ const event = finalizeEvent(calendarEvent, sk);
+
+ const isGood = verifyEvent(event);
+ console.log("event", event);
+ if (isGood) {
+ await Promise.all(pool.publish(relays, event));
+ }
+ } else if (type === BookingEventType.NEW_REQUEST || type === BookingEventType.NEW_REQUEST_COMMENT) {
+ // For requests and request comments, we don't create calendar entries
+ // They are handled by email notifications only
+ console.log(`Request event of type ${type} received - no calendar entry needed`);
+ }
+
+ return { success: true };
+};
+
+function dateToTimestamp(dateString: string): string {
+ const date = new Date(dateString);
+ return Math.floor(date.getTime() / 1000).toString();
+}
diff --git a/supabase/functions/edge-requests/src/index.ts b/supabase/functions/edge-requests/src/index.ts
new file mode 100644
index 0000000..a6c01a2
--- /dev/null
+++ b/supabase/functions/edge-requests/src/index.ts
@@ -0,0 +1,177 @@
+import "jsr:@supabase/functions-js/edge-runtime.d.ts";
+
+// Setup type definitions for built-in Supabase Runtime APIs
+import { handleCalendarEntry } from "./handleCalendar.ts";
+import { sendEmail } from "./sendEmail.ts";
+import { createClient } from "npm:@supabase/supabase-js@2";
+
+// CORS middleware function
+function withCors(handler: (req: Request) => Promise): (req: Request) => Promise {
+ return async (req: Request) => {
+ // Handle CORS preflight requests
+ if (req.method === 'OPTIONS') {
+ return new Response(null, {
+ status: 200,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
+ 'Access-Control-Allow-Headers': '*',
+ 'Access-Control-Max-Age': '86400',
+ },
+ });
+ }
+
+ // Call the original handler
+ const response = await handler(req);
+
+
+ // Add CORS headers to the response
+ const newHeaders = new Headers(response.headers);
+ newHeaders.set('Access-Control-Allow-Origin', '*');
+ newHeaders.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ newHeaders.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Info, X-Client-Name, X-Client-Version');
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers: newHeaders,
+ });
+ };
+}
+
+export async function handler(req: Request) {
+ try {
+ // Handle direct function calls from frontend
+ // The Supabase client handles authentication automatically
+ const payload = await req.json();
+ console.log(payload);
+ const { record, type } = payload;
+
+ // Create Supabase client for database operations
+ const supabaseUrl = (globalThis as any).Deno?.env?.get("SUPABASE_URL");
+ const supabaseServiceKey = (globalThis as any).Deno?.env?.get("SUPABASE_SERVICE_ROLE_KEY");
+
+ if (!supabaseUrl || !supabaseServiceKey) {
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: "Missing Supabase configuration",
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: 500,
+ }
+ );
+ }
+
+ const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+ // Handle database operations based on type
+ let dbResult;
+ if (type === 'new_booking') {
+ dbResult = await supabase.from('bookings').insert(record);
+ } else if (type === 'new_request') {
+ dbResult = await supabase.from('requests').insert(record);
+ } else if (type === 'new_request_comment') {
+ dbResult = await supabase.from('request_comments').insert(record);
+ } else if (type === 'confirmed_booking') {
+ // Update existing booking with approval data
+ const { id, ...updateData } = record;
+ dbResult = await supabase.from('bookings').update(updateData).eq('id', id);
+ } else {
+ // For other types, we don't perform database operations
+ dbResult = { error: null };
+ }
+
+ if (dbResult.error) {
+ console.error("Database operation error:", dbResult.error);
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: "Failed to perform database operation",
+ details: dbResult.error,
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: 500,
+ }
+ );
+ }
+
+ // Send email notifications
+ const emailDisabled = (globalThis as any).Deno?.env?.get("EMAIL_DISABLED") === "true";
+ if (!emailDisabled) {
+ const result = await sendEmail(record, type);
+ if (result.error) {
+ return result.error;
+ }
+ } else {
+ console.log("Email disabled, skipping email");
+ }
+
+ // Handle calendar entries
+ const resultCalendar = await handleCalendarEntry(
+ record,
+ type
+ );
+ if (resultCalendar && 'error' in resultCalendar && resultCalendar.error) {
+ return resultCalendar.error;
+ }
+
+ // Log a success message
+ console.log(
+ `Database operation completed, email sent, and calendar entry handled for ${type}`
+ );
+ return new Response(
+ JSON.stringify({
+ success: true,
+ message: "Operation completed and notification sent",
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ } catch (error) {
+ // Log the error
+ if (error instanceof Error) {
+ console.error("Error processing request:", error.message);
+ console.error("Stack trace:", error.stack);
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: error.message,
+ stack: error.stack,
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: 400,
+ }
+ );
+ } else {
+ console.error("Error processing request:", error);
+ return new Response(
+ JSON.stringify({
+ success: false,
+ error: String(error),
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: 400,
+ }
+ );
+ }
+ }
+}
+
+// @ts-expect-error - Deno serve API
+(globalThis as any).Deno?.serve?.length === 1 ? (globalThis as any).Deno.serve(withCors(handler)) : (globalThis as any).Deno.serve({ port: 8000 }, withCors(handler));
diff --git a/supabase/functions/edge-requests/src/language-utils.ts b/supabase/functions/edge-requests/src/language-utils.ts
new file mode 100644
index 0000000..bc5c5d6
--- /dev/null
+++ b/supabase/functions/edge-requests/src/language-utils.ts
@@ -0,0 +1,51 @@
+import { EmailTemplate, englishTemplate } from "./templates/english.ts";
+import { frenchTemplate } from "./templates/french.ts";
+import { dutchTemplate } from "./templates/dutch.ts";
+
+// Language code mapping
+export type SupportedLanguage = "en" | "fr" | "nl";
+
+// Language detection function
+export function detectLanguage(
+ languageCode: string | null | undefined
+): SupportedLanguage {
+ if (!languageCode) {
+ return "en"; // Default to English if no language specified
+ }
+
+ // Normalize the language code to lowercase
+ const normalizedCode = languageCode.toLowerCase().trim();
+
+ // Handle different language code formats
+ if (normalizedCode.startsWith("fr")) {
+ return "fr";
+ } else if (
+ normalizedCode.startsWith("nl") ||
+ normalizedCode.startsWith("du")
+ ) {
+ return "nl";
+ } else {
+ return "en"; // Default to English for any other language
+ }
+}
+
+// Get email template based on language
+export function getEmailTemplate(language: SupportedLanguage): EmailTemplate {
+ switch (language) {
+ case "fr":
+ return frenchTemplate;
+ case "nl":
+ return dutchTemplate;
+ case "en":
+ default:
+ return englishTemplate;
+ }
+}
+
+// Convenience function to get template directly from language code
+export function getTemplateFromLanguageCode(
+ languageCode: string | null | undefined
+): EmailTemplate {
+ const detectedLanguage = detectLanguage(languageCode);
+ return getEmailTemplate(detectedLanguage);
+}
diff --git a/supabase/functions/edge-requests/src/lib/google-calendar-oauth.ts b/supabase/functions/edge-requests/src/lib/google-calendar-oauth.ts
new file mode 100644
index 0000000..5a82429
--- /dev/null
+++ b/supabase/functions/edge-requests/src/lib/google-calendar-oauth.ts
@@ -0,0 +1,54 @@
+import { google } from "googleapis";
+
+export interface CalendarEvent {
+ title: string;
+ description?: string;
+ startTime: string; // ISO 8601 format
+ endTime: string; // ISO 8601 format
+ location?: string;
+ attendees?: string[]; // Array of email addresses
+}
+
+export class GoogleCalendarServiceOAuth {
+ private calendar: any;
+ private oauth2Client: any;
+
+ constructor() {
+ this.oauth2Client = new google.auth.OAuth2(
+ Deno.env.get("GOOGLE_CLIENT_ID"),
+ Deno.env.get("GOOGLE_CLIENT_SECRET"),
+ Deno.env.get("GOOGLE_REDIRECT_URI")
+ );
+
+ // Set credentials if you have a refresh token
+ const refreshToken = Deno.env.get("GOOGLE_REFRESH_TOKEN");
+ if (refreshToken) {
+ this.oauth2Client.setCredentials({
+ refresh_token: refreshToken,
+ });
+ }
+
+ this.calendar = google.calendar({ version: "v3", auth: this.oauth2Client });
+ }
+
+ // Generate authorization URL (call this once to get the authorization code)
+ getAuthUrl(): string {
+ const scopes = ["https://www.googleapis.com/auth/calendar"];
+ return this.oauth2Client.generateAuthUrl({
+ access_type: "offline",
+ scope: scopes,
+ });
+ }
+
+ // Exchange authorization code for tokens (call this once after user authorizes)
+ async getTokens(code: string): Promise {
+ const { tokens } = await this.oauth2Client.getToken(code);
+ this.oauth2Client.setCredentials(tokens);
+ return tokens;
+ }
+
+ // Same methods as the service account version...
+ async createEvent(calendarId: string, event: CalendarEvent): Promise {
+ // ... implementation same as above
+ }
+}
diff --git a/supabase/functions/edge-requests/src/lib/google-calendar.ts b/supabase/functions/edge-requests/src/lib/google-calendar.ts
new file mode 100644
index 0000000..5a98698
--- /dev/null
+++ b/supabase/functions/edge-requests/src/lib/google-calendar.ts
@@ -0,0 +1,133 @@
+import { google } from "npm:googleapis";
+
+export interface CalendarEvent {
+ title: string;
+ description?: string;
+ startTime: string; // ISO 8601 format
+ endTime: string; // ISO 8601 format
+ location?: string;
+ attendees?: string[]; // Array of email addresses
+}
+
+export class GoogleCalendarService {
+ private calendar: any;
+ private auth: any;
+
+ constructor() {
+ // Initialize Google Auth with service account or OAuth2
+ this.auth = new google.auth.GoogleAuth({
+ // Option 1: Service Account (recommended for server-side)
+ keyFile: Deno.env.get("GOOGLE_SERVICE_ACCOUNT_KEY_FILE"), // Path to service account JSON
+ scopes: ["https://www.googleapis.com/auth/calendar"],
+ });
+
+ // Option 2: Using service account key directly from environment
+ // const serviceAccountKey = JSON.parse(Deno.env.get("GOOGLE_SERVICE_ACCOUNT_KEY") || "{}");
+ // this.auth = new google.auth.GoogleAuth({
+ // credentials: serviceAccountKey,
+ // scopes: ["https://www.googleapis.com/auth/calendar"],
+ // });
+
+ this.calendar = google.calendar({ version: "v3", auth: this.auth });
+ }
+
+ async createEvent(calendarId: string, event: CalendarEvent): Promise {
+ try {
+ const response = await this.calendar.events.insert({
+ calendarId: calendarId,
+ requestBody: {
+ summary: event.title,
+ description: event.description,
+ start: {
+ dateTime: event.startTime,
+ timeZone: "Europe/Brussels", // Adjust timezone as needed
+ },
+ end: {
+ dateTime: event.endTime,
+ timeZone: "Europe/Brussels",
+ },
+ location: event.location,
+ attendees: event.attendees?.map((email) => ({ email })) || [],
+ reminders: {
+ useDefault: false,
+ overrides: [
+ { method: "email", minutes: 24 * 60 }, // 24 hours before
+ { method: "popup", minutes: 10 }, // 10 minutes before
+ ],
+ },
+ },
+ });
+
+ console.log("Calendar event created:", response.data.htmlLink);
+ return response.data;
+ } catch (error) {
+ console.error("Error creating calendar event:", error);
+ throw error;
+ }
+ }
+
+ async updateEvent(
+ calendarId: string,
+ eventId: string,
+ event: Partial
+ ): Promise {
+ try {
+ const response = await this.calendar.events.patch({
+ calendarId: calendarId,
+ eventId: eventId,
+ requestBody: {
+ summary: event.title,
+ description: event.description,
+ start: event.startTime
+ ? {
+ dateTime: event.startTime,
+ timeZone: "Europe/Brussels",
+ }
+ : undefined,
+ end: event.endTime
+ ? {
+ dateTime: event.endTime,
+ timeZone: "Europe/Brussels",
+ }
+ : undefined,
+ location: event.location,
+ attendees: event.attendees?.map((email) => ({ email })) || [],
+ },
+ });
+
+ console.log("Calendar event updated:", response.data.htmlLink);
+ return response.data;
+ } catch (error) {
+ console.error("Error updating calendar event:", error);
+ throw error;
+ }
+ }
+
+ async deleteEvent(calendarId: string, eventId: string): Promise {
+ try {
+ await this.calendar.events.delete({
+ calendarId: calendarId,
+ eventId: eventId,
+ });
+
+ console.log("Calendar event deleted");
+ } catch (error) {
+ console.error("Error deleting calendar event:", error);
+ throw error;
+ }
+ }
+
+ async getEvent(calendarId: string, eventId: string): Promise {
+ try {
+ const response = await this.calendar.events.get({
+ calendarId: calendarId,
+ eventId: eventId,
+ });
+
+ return response.data;
+ } catch (error) {
+ console.error("Error getting calendar event:", error);
+ throw error;
+ }
+ }
+}
diff --git a/supabase/functions/edge-requests/src/lib/nip-52.ts b/supabase/functions/edge-requests/src/lib/nip-52.ts
new file mode 100644
index 0000000..2d0ba96
--- /dev/null
+++ b/supabase/functions/edge-requests/src/lib/nip-52.ts
@@ -0,0 +1,52 @@
+import { EventTemplate, type UnsignedEvent } from "nostr-tools";
+
+export interface CalendarDateBasedTemplateEvent extends EventTemplate {
+ kind: 31922;
+ tags: Array<
+ | ["a", string] // community reference
+ | ["d", string] // random identifier
+ | ["title", string] // title of calendar event
+ | ["start", string] // YYYY-MM-DD
+ | ["end", string] // YYYY-MM-DD
+ | ["location", string] // location
+ | ["g", string] // geohash
+ // | ['p', string, string, string] // participants with relay URL and role
+ // | ['p', string, string] // participants with role only
+ >;
+}
+
+export interface CalendarTimeBasedTemplateEvent extends EventTemplate {
+ kind: 31923;
+ tags: Array<
+ | ["a", string] // community reference
+ | ["d", string] // random identifier
+ | ["title", string] // title of calendar event
+ | ["image", string] // image URL for the event
+ | ["start", string] // unix timestamp in seconds
+ | ["end", string] // unix timestamp in seconds
+ | ["start_tzid", string] // timezone ID for start time
+ | ["end_tzid", string] // timezone ID for end time
+ | ["location", string] // location
+ | ["g", string] // geohash
+ | ["t", string] // tags (repeated)
+ | ["r", string] // references (repeated)
+ // | ['p', string, string, string] // participants with relay URL and role
+ // | ['p', string, string] // participants with role only
+ >;
+}
+
+export function validateCalendarDateBasedTemplateEvent(
+ event: EventTemplate
+): event is CalendarDateBasedTemplateEvent {
+ if (event.kind !== 31922) return false;
+
+ // Find start and end tags
+ const startTag = event.tags.find((tag) => tag[0] === "start");
+ const endTag = event.tags.find((tag) => tag[0] === "end");
+
+ // Check if start and end tags exist and match YYYY-MM-DD format
+ if (!startTag?.[1] || !endTag?.[1]) return false;
+
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ return dateRegex.test(startTag[1]) && dateRegex.test(endTag[1]);
+}
diff --git a/supabase/functions/edge-requests/src/lib/nip-72.ts b/supabase/functions/edge-requests/src/lib/nip-72.ts
new file mode 100644
index 0000000..611a7b7
--- /dev/null
+++ b/supabase/functions/edge-requests/src/lib/nip-72.ts
@@ -0,0 +1,3 @@
+export const getCommunityATag = (community_id: string, community_identifier: string) => {
+ return `34550:${community_id}:${community_identifier}`;
+}
\ No newline at end of file
diff --git a/supabase/functions/edge-requests/src/sendEmail.ts b/supabase/functions/edge-requests/src/sendEmail.ts
new file mode 100644
index 0000000..91653c2
--- /dev/null
+++ b/supabase/functions/edge-requests/src/sendEmail.ts
@@ -0,0 +1,116 @@
+// Setup type definitions for built-in Supabase Runtime APIs
+import { SMTPClient } from "https://deno.land/x/denomailer/mod.ts";
+import { getTemplateFromLanguageCode } from "./language-utils.ts";
+
+export const sendEmail = async (
+ record: any,
+ type: string,
+) => {
+ // Récupérer les variables d'environnement
+ const emailHost = Deno.env.get("EMAIL_HOST");
+ const emailPort = parseInt(Deno.env.get("EMAIL_PORT") || "587");
+ const emailUser = Deno.env.get("EMAIL_USER");
+ const emailPass = Deno.env.get("EMAIL_PASS");
+ const officeManagerEmail = Deno.env.get("OFFICE_MANAGER_EMAIL");
+ // Vérifier si les variables d'environnement sont définies
+ if (
+ !emailHost ||
+ !emailUser ||
+ !emailPass ||
+ !officeManagerEmail
+ ) {
+ console.error("Missing email configuration in environment variables");
+ return {
+ error: new Response(
+ JSON.stringify({
+ success: false,
+ error: "Email configuration incomplete",
+ missing: {
+ host: !emailHost,
+ user: !emailUser,
+ pass: !emailPass,
+ recipient: !officeManagerEmail,
+ },
+ }),
+ {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ status: 500,
+ }
+ ),
+ };
+ }
+ // Configure email client avec les variables d'environnement
+ const client = new SMTPClient({
+ connection: {
+ hostname: emailHost,
+ port: emailPort,
+ tls: true,
+ auth: {
+ username: emailUser,
+ password: emailPass,
+ },
+ },
+ });
+ console.log("Connecting to SMTP server:", emailHost, emailPort);
+
+ // Format the date and time for better readability
+ const startDateTime = new Date(record.start_time);
+ const endDateTime = new Date(record.end_time);
+ const formattedStartDate = startDateTime.toLocaleDateString();
+ const formattedStartTime = startDateTime.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ const formattedEndTime = endDateTime.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+
+ // Get the appropriate email template based on the booking's language
+ console.log("Booking language:", record.language);
+ const emailTemplate = getTemplateFromLanguageCode(record.language);
+
+ let subject = "";
+ let body = "";
+
+ // Determine email content based on the event type
+ if (type === "new_booking") {
+ subject = emailTemplate.newBooking.subject(record.title);
+ body = emailTemplate.newBooking.body(
+ record,
+ formattedStartDate,
+ formattedStartTime,
+ formattedEndTime
+ );
+ } else if (type === "confirmed_booking") {
+ subject = emailTemplate.confirmedBooking.subject(record.title);
+ body = emailTemplate.confirmedBooking.body(
+ record,
+ formattedStartDate,
+ formattedStartTime,
+ formattedEndTime
+ );
+ } else if (type === "new_request") {
+ subject = emailTemplate.newRequest.subject(record.title);
+ body = emailTemplate.newRequest.body(record);
+ } else if (type === "new_request_comment") {
+ // For request comments, we need to get the request title
+ // Since we only have the comment record, we'll use a generic title
+ const requestTitle = `Request #${record.request_id}`;
+ subject = emailTemplate.newRequestComment.subject(requestTitle);
+ body = emailTemplate.newRequestComment.body(record);
+ }
+ console.log(`Sending email to ${officeManagerEmail} - Subject: ${subject}`);
+ // Send the email
+ await client.send({
+ from: emailUser,
+ to: officeManagerEmail,
+ cc: record.created_by_email,
+ subject: subject,
+ content: body,
+ });
+ await client.close();
+ return { success: true };
+};
diff --git a/supabase/functions/edge-requests/src/templates/dutch.ts b/supabase/functions/edge-requests/src/templates/dutch.ts
new file mode 100644
index 0000000..bbcf322
--- /dev/null
+++ b/supabase/functions/edge-requests/src/templates/dutch.ts
@@ -0,0 +1,69 @@
+import { EmailTemplate } from "./english.ts";
+
+export const dutchTemplate: EmailTemplate = {
+ newBooking: {
+ subject: (title: string) => `Nieuwe reserveringsaanvraag: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `Een nieuwe reserveringsaanvraag is ontvangen.
+
+Reserveringsinformatie:
+----------------------------
+Titel: ${record.title}
+Beschrijving: ${record.description || "Geen"}
+Ruimte: ${record.room_name} (capaciteit: ${record.room_capacity})
+Datum: ${formattedStartDate}
+Tijd: ${formattedStartTime} - ${formattedEndTime}
+Aangemaakt door: ${record.created_by_name || "Niet gespecificeerd"} (${record.created_by_email})
+
+Om deze reservering goed te keuren, log in op het reserveringssysteem.`
+ },
+ confirmedBooking: {
+ subject: (title: string) => `Reservering bevestigd: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `Een reservering is bevestigd.
+
+Reserveringsinformatie:
+----------------------------
+Titel: ${record.title}
+Beschrijving: ${record.description || "Geen"}
+Ruimte: ${record.room_name} (capaciteit: ${record.room_capacity})
+Datum: ${formattedStartDate}
+Tijd: ${formattedStartTime} - ${formattedEndTime}
+Aangemaakt door: ${record.created_by_name || "Niet gespecificeerd"} (${record.created_by_email})
+Goedgekeurd door: ${record.approved_by_email}
+Goedkeuringsdatum: ${new Date(record.approved_at || "").toLocaleString()}`
+ },
+ newRequest: {
+ subject: (title: string) => `Nieuwe ondersteuningsaanvraag: ${title}`,
+ body: (record: any) => `Een nieuwe ondersteuningsaanvraag is ontvangen.
+
+Aanvraaginformatie:
+----------------------------
+Titel: ${record.title}
+Beschrijving: ${record.description}
+Type: ${record.request_type}
+Prioriteit: ${record.priority}
+Status: ${record.status}
+Aangemaakt door: ${record.created_by_name || "Niet gespecificeerd"} (${record.created_by_email})
+Contact e-mail: ${record.email}
+Contact naam: ${record.name}
+Telefoon: ${record.phone || "Niet opgegeven"}
+Organisatie: ${record.organization || "Niet gespecificeerd"}
+Verwachte voltooiingsdatum: ${record.expected_completion_date ? new Date(record.expected_completion_date).toLocaleDateString() : "Niet gespecificeerd"}
+Aanvullende details: ${record.additional_details || "Geen"}
+Bijlagen: ${record.attachments && record.attachments.length > 0 ? record.attachments.join(", ") : "Geen"}
+
+Om deze aanvraag te bekijken, log in op het reserveringssysteem.`
+ },
+ newRequestComment: {
+ subject: (requestTitle: string) => `Nieuw commentaar op aanvraag: ${requestTitle}`,
+ body: (record: any) => `Er is een nieuw commentaar toegevoegd aan een ondersteuningsaanvraag.
+
+Commentaar informatie:
+----------------------------
+Aanvraag ID: ${record.request_id}
+Commentaar door: ${record.created_by_name || "Niet gespecificeerd"} (${record.created_by_email})
+Commentaar: ${record.content}
+Geplaatst op: ${new Date(record.created_at).toLocaleString()}
+
+Om de volledige aanvraag en het commentaar te bekijken, log in op het reserveringssysteem.`
+ }
+};
diff --git a/supabase/functions/edge-requests/src/templates/english.ts b/supabase/functions/edge-requests/src/templates/english.ts
new file mode 100644
index 0000000..c22c792
--- /dev/null
+++ b/supabase/functions/edge-requests/src/templates/english.ts
@@ -0,0 +1,86 @@
+export interface EmailTemplate {
+ newBooking: {
+ subject: (title: string) => string;
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => string;
+ };
+ confirmedBooking: {
+ subject: (title: string) => string;
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => string;
+ };
+ newRequest: {
+ subject: (title: string) => string;
+ body: (record: any) => string;
+ };
+ newRequestComment: {
+ subject: (requestTitle: string) => string;
+ body: (record: any) => string;
+ };
+}
+
+export const englishTemplate: EmailTemplate = {
+ newBooking: {
+ subject: (title: string) => `New booking request: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `A new booking request has been received.
+
+Booking Information:
+----------------------------
+Title: ${record.title}
+Description: ${record.description || "None"}
+Room: ${record.room_name} (capacity: ${record.room_capacity})
+Date: ${formattedStartDate}
+Time: ${formattedStartTime} - ${formattedEndTime}
+Created by: ${record.created_by_name || "Not specified"} (${record.created_by_email})
+
+To approve this booking, please log in to the booking system.`
+ },
+ confirmedBooking: {
+ subject: (title: string) => `Booking confirmed: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `A booking has been confirmed.
+
+Booking Information:
+----------------------------
+Title: ${record.title}
+Description: ${record.description || "None"}
+Room: ${record.room_name} (capacity: ${record.room_capacity})
+Date: ${formattedStartDate}
+Time: ${formattedStartTime} - ${formattedEndTime}
+Created by: ${record.created_by_name || "Not specified"} (${record.created_by_email})
+Approved by: ${record.approved_by_email}
+Approval date: ${new Date(record.approved_at || "").toLocaleString()}`
+ },
+ newRequest: {
+ subject: (title: string) => `New support request: ${title}`,
+ body: (record: any) => `A new support request has been received.
+
+Request Information:
+----------------------------
+Title: ${record.title}
+Description: ${record.description}
+Type: ${record.request_type}
+Priority: ${record.priority}
+Status: ${record.status}
+Created by: ${record.created_by_name || "Not specified"} (${record.created_by_email})
+Contact Email: ${record.email}
+Contact Name: ${record.name}
+Phone: ${record.phone || "Not provided"}
+Organization: ${record.organization || "Not specified"}
+Expected Completion Date: ${record.expected_completion_date ? new Date(record.expected_completion_date).toLocaleDateString() : "Not specified"}
+Additional Details: ${record.additional_details || "None"}
+Attachments: ${record.attachments && record.attachments.length > 0 ? record.attachments.join(", ") : "None"}
+
+To review this request, please log in to the booking system.`
+ },
+ newRequestComment: {
+ subject: (requestTitle: string) => `New comment on request: ${requestTitle}`,
+ body: (record: any) => `A new comment has been added to a support request.
+
+Comment Information:
+----------------------------
+Request ID: ${record.request_id}
+Comment by: ${record.created_by_name || "Not specified"} (${record.created_by_email})
+Comment: ${record.content}
+Posted at: ${new Date(record.created_at).toLocaleString()}
+
+To view the full request and comment, please log in to the booking system.`
+ }
+};
diff --git a/supabase/functions/edge-requests/src/templates/french.ts b/supabase/functions/edge-requests/src/templates/french.ts
new file mode 100644
index 0000000..68451ab
--- /dev/null
+++ b/supabase/functions/edge-requests/src/templates/french.ts
@@ -0,0 +1,69 @@
+import { EmailTemplate } from "./english.ts";
+
+export const frenchTemplate: EmailTemplate = {
+ newBooking: {
+ subject: (title: string) => `Nouvelle demande de réservation: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `Une nouvelle demande de réservation a été reçue.
+
+Informations de réservation:
+----------------------------
+Titre: ${record.title}
+Description: ${record.description || "Aucune"}
+Salle: ${record.room_name} (capacité: ${record.room_capacity})
+Date: ${formattedStartDate}
+Horaire: ${formattedStartTime} - ${formattedEndTime}
+Créée par: ${record.created_by_name || "Non spécifié"} (${record.created_by_email})
+
+Pour approuver cette réservation, veuillez vous connecter au système de réservation.`
+ },
+ confirmedBooking: {
+ subject: (title: string) => `Réservation confirmée: ${title}`,
+ body: (record: any, formattedStartDate: string, formattedStartTime: string, formattedEndTime: string) => `Une réservation a été confirmée.
+
+Informations de réservation:
+----------------------------
+Titre: ${record.title}
+Description: ${record.description || "Aucune"}
+Salle: ${record.room_name} (capacité: ${record.room_capacity})
+Date: ${formattedStartDate}
+Horaire: ${formattedStartTime} - ${formattedEndTime}
+Créée par: ${record.created_by_name || "Non spécifié"} (${record.created_by_email})
+Approuvée par: ${record.approved_by_email}
+Date d'approbation: ${new Date(record.approved_at || "").toLocaleString()}`
+ },
+ newRequest: {
+ subject: (title: string) => `Nouvelle demande de support: ${title}`,
+ body: (record: any) => `Une nouvelle demande de support a été reçue.
+
+Informations de la demande:
+----------------------------
+Titre: ${record.title}
+Description: ${record.description}
+Type: ${record.request_type}
+Priorité: ${record.priority}
+Statut: ${record.status}
+Créée par: ${record.created_by_name || "Non spécifié"} (${record.created_by_email})
+Email de contact: ${record.email}
+Nom de contact: ${record.name}
+Téléphone: ${record.phone || "Non fourni"}
+Organisation: ${record.organization || "Non spécifiée"}
+Date de completion attendue: ${record.expected_completion_date ? new Date(record.expected_completion_date).toLocaleDateString() : "Non spécifiée"}
+Détails supplémentaires: ${record.additional_details || "Aucun"}
+Pièces jointes: ${record.attachments && record.attachments.length > 0 ? record.attachments.join(", ") : "Aucune"}
+
+Pour examiner cette demande, veuillez vous connecter au système de réservation.`
+ },
+ newRequestComment: {
+ subject: (requestTitle: string) => `Nouveau commentaire sur la demande: ${requestTitle}`,
+ body: (record: any) => `Un nouveau commentaire a été ajouté à une demande de support.
+
+Informations du commentaire:
+----------------------------
+ID de la demande: ${record.request_id}
+Commentaire par: ${record.created_by_name || "Non spécifié"} (${record.created_by_email})
+Commentaire: ${record.content}
+Posté le: ${new Date(record.created_at).toLocaleString()}
+
+Pour voir la demande complète et le commentaire, veuillez vous connecter au système de réservation.`
+ }
+};
diff --git a/supabase/functions/edge-requests/tests/booking_approval.test.ts b/supabase/functions/edge-requests/tests/booking_approval.test.ts
new file mode 100644
index 0000000..3810365
--- /dev/null
+++ b/supabase/functions/edge-requests/tests/booking_approval.test.ts
@@ -0,0 +1,49 @@
+// Will load the .env file to Deno.env
+import 'jsr:@std/dotenv/load'
+import { createClient, SupabaseClient } from 'npm:@supabase/supabase-js@2'
+import { assertEquals } from "jsr:@std/assert";
+
+const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
+const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
+const options = {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ detectSessionInUrl: false,
+ },
+}
+
+Deno.test("invoke booking-notification for booking approval", async () => {
+ const client: SupabaseClient = createClient(supabaseUrl, supabaseKey, options)
+ const payload = {
+ record: {
+ id: 2,
+ title: "Approved Booking",
+ description: "A booking that has been approved",
+ room_id: 102,
+ room_name: "Approved Room",
+ room_capacity: 20,
+ start_time: "2024-06-02T10:00:00Z",
+ end_time: "2024-06-02T12:00:00Z",
+ status: "approved",
+ created_by_email: "approver@example.com",
+ created_by_name: "Approver User",
+ created_at: "2024-06-02T09:00:00Z",
+ approved_by_email: "admin@example.com",
+ approved_at: "2024-06-02T09:30:00Z"
+ },
+ type: "confirmed_booking"
+ };
+
+ const { data, error } = await client.functions.invoke('bookingnotifications', {
+ headers: {
+ 'x-supabase-webhook-source': Deno.env.get('TRIGGER_AUTH') ?? ''
+ },
+ body: payload,
+ })
+ if (error) {
+ throw new Error('Invalid response: ' + error.message + "(" + error.context.status + ": " + error.context.statusText + ")")
+ }
+ assertEquals(data.success, true)
+ assertEquals(data.message, "Notification email sent")
+});
\ No newline at end of file
diff --git a/supabase/functions/edge-requests/tests/new_booking.test.ts b/supabase/functions/edge-requests/tests/new_booking.test.ts
new file mode 100644
index 0000000..24ff20d
--- /dev/null
+++ b/supabase/functions/edge-requests/tests/new_booking.test.ts
@@ -0,0 +1,74 @@
+// Test for handling new booking
+// @ts-ignore: Deno runtime import
+// import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
+import { handler } from "../index.ts";
+import { assertEquals } from "jsr:@std/assert";
+
+// Will load the .env file to Deno.env
+import 'jsr:@std/dotenv/load'
+import { createClient, SupabaseClient } from 'npm:@supabase/supabase-js@2'
+
+const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
+const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') ?? ''
+const options = {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ detectSessionInUrl: false,
+ },
+}
+
+Deno.test("invoke booking-notification for new booking", async () => {
+ const client: SupabaseClient = createClient(supabaseUrl, supabaseKey, options)
+ const payload = {
+ record: {
+ id: 1,
+ title: "Test Booking",
+ description: "A test booking event",
+ room_id: 101,
+ room_name: "Test Room",
+ room_capacity: 10,
+ start_time: "2024-06-01T10:00:00Z",
+ end_time: "2024-06-01T12:00:00Z",
+ status: "pending",
+ created_by_email: "test@example.com",
+ created_by_name: "Test User",
+ created_at: "2024-06-01T09:00:00Z",
+ approved_by_email: null,
+ approved_at: null,
+ additional_comments: null,
+ is_public_event: false,
+ cancelled_at: null,
+ cancelled_by_email: null,
+ organizer: "Test Org",
+ estimated_attendees: 5,
+ luma_event_url: null,
+ calendar_url: null,
+ public_uri: null,
+ language: "en",
+ price: null,
+ currency: null,
+ catering_options: null,
+ catering_comments: null,
+ event_support_options: null,
+ membership_status: null
+ },
+ type: "new_booking"
+ };
+ console.log('payload', payload)
+ const triggerAuth = Deno.env.get('TRIGGER_AUTH') ?? ''
+ console.log('triggerAuth', triggerAuth)
+ const { data, error } = await client.functions.invoke('bookingnotifications', {
+ body: payload,
+ headers: {
+ 'x-supabase-webhook-source': triggerAuth
+ }
+ })
+ console.log('data', data)
+ console.log('error', error)
+ if (error) {
+ throw new Error('Invalid response: ' + error.message + "(" + error.context.status + ": " + error.context.statusText + ")")
+ }
+ assertEquals(data.success, true)
+ assertEquals(data.message, "Notification email sent")
+});
\ No newline at end of file