Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
389
wacrm/src/hooks/use-auth.tsx
Normal file
389
wacrm/src/hooks/use-auth.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { DEFAULT_CURRENCY } from "@/lib/currency";
|
||||
import {
|
||||
canEditSettings as canEditSettingsFor,
|
||||
canManageMembers as canManageMembersFor,
|
||||
canSendMessages as canSendMessagesFor,
|
||||
isAccountRole,
|
||||
type AccountRole,
|
||||
} from "@/lib/auth/roles";
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string | null;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
role: string | null;
|
||||
/**
|
||||
* Opted-in beta feature keys for this account. No current feature
|
||||
* reads this — Flows was the last user and went to soft-GA in PR
|
||||
* #134 — but the column survives for future beta gates.
|
||||
*/
|
||||
beta_features: string[];
|
||||
account_id: string | null;
|
||||
account_role: AccountRole | null;
|
||||
}
|
||||
|
||||
interface AccountSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Default deal currency (ISO-4217). NOT NULL DEFAULT 'USD' in the
|
||||
* DB (migration 021); narrowed to DEFAULT_CURRENCY when absent. */
|
||||
default_currency: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
profile: Profile | null;
|
||||
/**
|
||||
* Session-level loading. Flips to false as soon as we know whether
|
||||
* a user is signed in, *without* waiting for the profile row. Use
|
||||
* this for chrome (sidebar / header) that can render with just the
|
||||
* user object.
|
||||
*/
|
||||
loading: boolean;
|
||||
/**
|
||||
* Profile-row loading. Stays true until `fetchProfile` settles
|
||||
* (success, missing row, or error). Code that branches on
|
||||
* `profile.beta_features` MUST gate on this — otherwise it sees the
|
||||
* `{ loading: false, profile: null }` window during initial load
|
||||
* and may take the "not opted in" branch incorrectly.
|
||||
*/
|
||||
profileLoading: boolean;
|
||||
signOut: () => Promise<void>;
|
||||
/** Re-fetch the current user's profile row — call after a save from
|
||||
* the settings form so header/sidebar reflect the change without a
|
||||
* full page reload. */
|
||||
refreshProfile: () => Promise<void>;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Account-scoped context (added by the account-sharing series)
|
||||
//
|
||||
// All of these are nullable until `profileLoading` is false.
|
||||
// After the profile resolves they're guaranteed to be set,
|
||||
// because migration 017 made `account_id` / `account_role`
|
||||
// NOT NULL on `profiles`.
|
||||
// ----------------------------------------------------------
|
||||
|
||||
/** Account id the current user belongs to. Null while loading. */
|
||||
accountId: string | null;
|
||||
/** Role within that account. Null while loading. */
|
||||
accountRole: AccountRole | null;
|
||||
/** Lightweight account meta — id + name + default_currency. Null while loading. */
|
||||
account: AccountSummary | null;
|
||||
/** Account default deal currency. Falls back to DEFAULT_CURRENCY
|
||||
* while loading or when no account is resolved, so callers can use
|
||||
* it unconditionally. */
|
||||
defaultCurrency: string;
|
||||
/** True if `accountRole === 'owner'`. */
|
||||
isOwner: boolean;
|
||||
/** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */
|
||||
isAdmin: boolean;
|
||||
/** True if `accountRole === 'agent'`. */
|
||||
isAgent: boolean;
|
||||
/** True if `accountRole === 'viewer'`. */
|
||||
isViewer: boolean;
|
||||
/** True if the caller can manage members (admin+). */
|
||||
canManageMembers: boolean;
|
||||
/** True if the caller can edit account-wide settings (admin+). */
|
||||
canEditSettings: boolean;
|
||||
/** True if the caller can send messages and edit operational data (agent+). */
|
||||
canSendMessages: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* AuthProvider — wrap this around the dashboard layout.
|
||||
* Makes ONE getSession() call for the whole tree instead of one per
|
||||
* component, avoiding internal lock contention in the Supabase client.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [account, setAccount] = useState<AccountSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Tracked separately from `loading`. The session settles fast (one
|
||||
// local cookie read); the profile fetch crosses the network and
|
||||
// settles later. Callers that gate on `profile.*` need to know which
|
||||
// window they're in — see the type doc above.
|
||||
const [profileLoading, setProfileLoading] = useState(true);
|
||||
|
||||
// Tracks the user ID we've successfully initiated/completed fetching
|
||||
// a profile for. This prevents redundant re-fetches and toggling
|
||||
// profileLoading back to true on window focus events/token refresh.
|
||||
const lastFetchedUserIdRef = useRef<string | null>(null);
|
||||
|
||||
// Shared across init, auth-state-change listener, and the exposed
|
||||
// refreshProfile() callback. Reads the current session's user id and
|
||||
// pulls the matching profile row along with its account summary.
|
||||
const fetchProfile = useCallback(async (userId: string) => {
|
||||
const supabase = createClient();
|
||||
setProfileLoading(true);
|
||||
lastFetchedUserIdRef.current = userId;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id, full_name, email, avatar_url, role, beta_features, account_id, account_role",
|
||||
)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("[AuthProvider] fetchProfile error:", {
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
hint: error.hint,
|
||||
code: error.code,
|
||||
});
|
||||
lastFetchedUserIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Load the account with a plain lookup by id instead of an
|
||||
// embedded FK join. The embed (`account:accounts!inner(...)`)
|
||||
// forces PostgREST to resolve the profiles.account_id →
|
||||
// accounts.id relationship from its schema cache; a stale cache
|
||||
// (common right after a migration adds the FK) makes it fail
|
||||
// hard with PGRST200 and blanks the whole profile — the user
|
||||
// then loses account context everywhere (issue #294). A point
|
||||
// lookup by id needs no relationship inference, so the profile
|
||||
// (with account_id / account_role) still resolves even if the
|
||||
// account name lookup itself can't.
|
||||
let accountRow: AccountSummary | null = null;
|
||||
if (data.account_id) {
|
||||
const { data: account, error: accountErr } = await supabase
|
||||
.from("accounts")
|
||||
// default_currency added in migration 021; narrowed to the
|
||||
// USD fallback below for older schemas where it reads null.
|
||||
.select("id, name, default_currency")
|
||||
.eq("id", data.account_id)
|
||||
.maybeSingle();
|
||||
if (accountErr) {
|
||||
console.error("[AuthProvider] fetchAccount error:", {
|
||||
message: accountErr.message,
|
||||
details: accountErr.details,
|
||||
hint: accountErr.hint,
|
||||
code: accountErr.code,
|
||||
});
|
||||
} else if (account) {
|
||||
accountRow = {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
default_currency: account.default_currency ?? DEFAULT_CURRENCY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow the DB enum into our AccountRole union. The DB
|
||||
// constraint should make this unconditional, but a future
|
||||
// migration that broadens the enum without updating TS would
|
||||
// otherwise crash here — fall back to null and let UI gates
|
||||
// treat the caller as least-privileged.
|
||||
const accountRole = isAccountRole(data.account_role)
|
||||
? data.account_role
|
||||
: null;
|
||||
|
||||
setProfile({
|
||||
id: data.id,
|
||||
full_name: data.full_name,
|
||||
email: data.email,
|
||||
avatar_url: data.avatar_url,
|
||||
role: data.role,
|
||||
// `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
|
||||
// narrow defensively in case the column hasn't been migrated yet
|
||||
// (older deployments running 011 lazily) — `null` reads as no
|
||||
// opt-ins, which is the safe default for any future beta gate.
|
||||
beta_features: data.beta_features ?? [],
|
||||
account_id: data.account_id ?? null,
|
||||
account_role: accountRole,
|
||||
});
|
||||
setAccount(accountRow);
|
||||
} else {
|
||||
lastFetchedUserIdRef.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[AuthProvider] fetchProfile threw:", err);
|
||||
lastFetchedUserIdRef.current = null;
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let mounted = true;
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
if (mounted) {
|
||||
console.warn("[AuthProvider] getSession() timed out after 3s");
|
||||
setLoading(false);
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (error) console.error("[AuthProvider] getSession error:", error.message);
|
||||
|
||||
if (!mounted) return;
|
||||
const currentUser = session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
// Don't block session loading on profile fetch — chrome
|
||||
// (header, sidebar) can render from the user object alone,
|
||||
// profile enriches async. Callers that need to branch on
|
||||
// profile data gate on `profileLoading` instead.
|
||||
fetchProfile(currentUser.id);
|
||||
} else {
|
||||
// No user → no profile to load. Flip profileLoading off so
|
||||
// pages that gate on it don't wait forever on the logged-out
|
||||
// path (the route guard or redirect should fire instead).
|
||||
setProfileLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[AuthProvider] init threw:", err);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
clearTimeout(safetyTimer);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!mounted) return;
|
||||
const currentUser = session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
if (currentUser.id !== lastFetchedUserIdRef.current) {
|
||||
fetchProfile(currentUser.id);
|
||||
}
|
||||
} else {
|
||||
lastFetchedUserIdRef.current = null;
|
||||
setProfile(null);
|
||||
setAccount(null);
|
||||
setProfileLoading(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearTimeout(safetyTimer);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [fetchProfile]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
setProfile(null);
|
||||
setAccount(null);
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const refreshProfile = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
await fetchProfile(user.id);
|
||||
}, [user?.id, fetchProfile]);
|
||||
|
||||
// Derive the role booleans once per profile change rather than on
|
||||
// every consumer render. Cheap regardless, but the memo also gives
|
||||
// each derived value a stable identity for React.memo / useEffect
|
||||
// dependencies downstream.
|
||||
const derived = useMemo(() => {
|
||||
const role = profile?.account_role ?? null;
|
||||
return {
|
||||
accountRole: role,
|
||||
accountId: profile?.account_id ?? null,
|
||||
isOwner: role === "owner",
|
||||
isAdmin: role === "admin",
|
||||
isAgent: role === "agent",
|
||||
isViewer: role === "viewer",
|
||||
canManageMembers: role ? canManageMembersFor(role) : false,
|
||||
canEditSettings: role ? canEditSettingsFor(role) : false,
|
||||
canSendMessages: role ? canSendMessagesFor(role) : false,
|
||||
};
|
||||
}, [profile?.account_role, profile?.account_id]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
profile,
|
||||
loading,
|
||||
profileLoading,
|
||||
signOut,
|
||||
refreshProfile,
|
||||
account,
|
||||
defaultCurrency: account?.default_currency ?? DEFAULT_CURRENCY,
|
||||
...derived,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useAuth — read the shared auth state from context.
|
||||
* Must be used inside an <AuthProvider>.
|
||||
*/
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
// Fallback for components rendered outside the provider (shouldn't
|
||||
// happen in normal flow, but don't crash the page). Account state
|
||||
// collapses to least-privileged null — every `canX` boolean is
|
||||
// false so UI gates fail closed.
|
||||
return {
|
||||
user: null,
|
||||
profile: null,
|
||||
loading: false,
|
||||
profileLoading: false,
|
||||
signOut: async () => {
|
||||
window.location.href = "/login";
|
||||
},
|
||||
refreshProfile: async () => {},
|
||||
account: null,
|
||||
defaultCurrency: DEFAULT_CURRENCY,
|
||||
accountId: null,
|
||||
accountRole: null,
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
isAgent: false,
|
||||
isViewer: false,
|
||||
canManageMembers: false,
|
||||
canEditSettings: false,
|
||||
canSendMessages: false,
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
575
wacrm/src/hooks/use-broadcast-sending.ts
Normal file
575
wacrm/src/hooks/use-broadcast-sending.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { Contact, MessageTemplate } from '@/types';
|
||||
|
||||
export type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||
|
||||
export interface CustomFieldFilter {
|
||||
fieldId: string;
|
||||
operator: CustomFieldOperator;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AudienceConfig {
|
||||
type: 'all' | 'tags' | 'custom_field' | 'csv';
|
||||
tagIds?: string[];
|
||||
customField?: CustomFieldFilter;
|
||||
csvContacts?: { phone: string; name?: string }[];
|
||||
/** Contacts carrying any of these tags are subtracted from the result. */
|
||||
excludeTagIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable mapping — each template placeholder (by key, usually "1",
|
||||
* "2", …) is resolved at send time. `field` maps to a built-in contact
|
||||
* field (name/phone/email/company); `custom_field` maps to a
|
||||
* contact_custom_values.value row keyed by the custom_fields.id stored
|
||||
* in `value`.
|
||||
*/
|
||||
export type VariableMapping =
|
||||
| { type: 'static'; value: string }
|
||||
| { type: 'field'; value: string }
|
||||
| { type: 'custom_field'; value: string };
|
||||
|
||||
interface BroadcastPayload {
|
||||
name: string;
|
||||
template: MessageTemplate;
|
||||
audience: AudienceConfig;
|
||||
variables: Record<string, VariableMapping>;
|
||||
/**
|
||||
* Media URL for an IMAGE/VIDEO/DOCUMENT header. Required at send
|
||||
* time for media-header templates — Meta rejects the send without
|
||||
* it. Passed through as `messageParams.headerMediaUrl`; the builder
|
||||
* falls back to the template's stored URL only when this is empty.
|
||||
*/
|
||||
headerMediaUrl?: string;
|
||||
}
|
||||
|
||||
interface UseBroadcastSendingReturn {
|
||||
createAndSendBroadcast: (payload: BroadcastPayload) => Promise<string>;
|
||||
isProcessing: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta rate-limit buffer. 10 per batch + 1 s pause matches the spec
|
||||
* and keeps us comfortably under Meta's per-phone-number messaging
|
||||
* rate so a large broadcast never trips the upstream limiter.
|
||||
*/
|
||||
const SEND_BATCH_SIZE = 10;
|
||||
const SEND_BATCH_DELAY_MS = 1000;
|
||||
|
||||
/** `broadcast_recipients` inserts are independent of the send rate. */
|
||||
const INSERT_BATCH_SIZE = 200;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface BroadcastApiResult {
|
||||
phone: string;
|
||||
status: 'sent' | 'failed';
|
||||
whatsapp_message_id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** contactId → (customFieldId → value). */
|
||||
type CustomValueIndex = Map<string, Map<string, string>>;
|
||||
|
||||
/**
|
||||
* Per-contact resolution of custom-field placeholders. Static and
|
||||
* built-in-field mappings resolve synchronously; custom fields read
|
||||
* from a pre-built index to avoid N+1 queries during the send loop.
|
||||
*/
|
||||
export function resolveVariables(
|
||||
variables: Record<string, VariableMapping>,
|
||||
contact: Contact,
|
||||
customValues?: Map<string, string>,
|
||||
): string[] {
|
||||
// Keys are typically "1","2",... — numeric-aware sort keeps
|
||||
// {{1}} before {{10}}.
|
||||
const keys = Object.keys(variables).sort((a, b) => {
|
||||
const an = Number(a);
|
||||
const bn = Number(b);
|
||||
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return keys.map((key) => {
|
||||
const v = variables[key];
|
||||
if (v.type === 'static') return v.value;
|
||||
|
||||
if (v.type === 'field') {
|
||||
const fieldMap: Record<string, string | undefined> = {
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
company: contact.company,
|
||||
};
|
||||
return fieldMap[v.value] ?? '';
|
||||
}
|
||||
|
||||
// custom_field
|
||||
return customValues?.get(v.value) ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-fetch contact_custom_values for a set of contacts. Returns an
|
||||
* index keyed by contact_id → field_id → value.
|
||||
*/
|
||||
async function fetchCustomValueIndex(
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
contactIds: string[],
|
||||
): Promise<CustomValueIndex> {
|
||||
const index: CustomValueIndex = new Map();
|
||||
if (contactIds.length === 0) return index;
|
||||
|
||||
// Supabase PostgREST caps the .in(...) IN-clause roughly at 1000
|
||||
// values. Page through to stay safe.
|
||||
const PAGE = 500;
|
||||
for (let i = 0; i < contactIds.length; i += PAGE) {
|
||||
const slice = contactIds.slice(i, i + PAGE);
|
||||
const { data } = await supabase
|
||||
.from('contact_custom_values')
|
||||
.select('contact_id, custom_field_id, value')
|
||||
.in('contact_id', slice);
|
||||
|
||||
for (const row of data ?? []) {
|
||||
const bucket = index.get(row.contact_id) ?? new Map<string, string>();
|
||||
bucket.set(row.custom_field_id, row.value ?? '');
|
||||
index.set(row.contact_id, bucket);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||
const { accountId } = useAuth();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
||||
const supabase = createClient();
|
||||
|
||||
let contacts: Contact[] = [];
|
||||
|
||||
if (audience.type === 'all') {
|
||||
const { data, error } = await supabase.from('contacts').select('*');
|
||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||
contacts = data ?? [];
|
||||
} else if (
|
||||
audience.type === 'tags' &&
|
||||
audience.tagIds &&
|
||||
audience.tagIds.length > 0
|
||||
) {
|
||||
const { data: contactTags, error: tagError } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id')
|
||||
.in('tag_id', audience.tagIds);
|
||||
|
||||
if (tagError)
|
||||
throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
|
||||
|
||||
if (contactTags && contactTags.length > 0) {
|
||||
const uniqueContactIds = [
|
||||
...new Set(contactTags.map((ct) => ct.contact_id)),
|
||||
];
|
||||
const { data, error } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.in('id', uniqueContactIds);
|
||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||
contacts = data ?? [];
|
||||
}
|
||||
} else if (audience.type === 'custom_field' && audience.customField) {
|
||||
contacts = await resolveCustomFieldAudience(supabase, audience.customField);
|
||||
} else if (audience.type === 'csv' && audience.csvContacts) {
|
||||
contacts = await upsertCsvContacts(supabase, audience.csvContacts);
|
||||
}
|
||||
|
||||
// Apply exclude tags (works across all contact-derived audience
|
||||
// types). CSV contacts are synthetic so exclusion doesn't apply.
|
||||
if (audience.excludeTagIds && audience.excludeTagIds.length > 0) {
|
||||
const { data: excludeRows } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id')
|
||||
.in('tag_id', audience.excludeTagIds);
|
||||
const excludedIds = new Set((excludeRows ?? []).map((r) => r.contact_id));
|
||||
contacts = contacts.filter((c) => !excludedIds.has(c.id));
|
||||
}
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV uploads arrive as raw phone/name pairs, not DB rows. Before we
|
||||
* can insert broadcast_recipients (whose contact_id FKs contacts.id),
|
||||
* we need real contacts.id UUIDs. So: look up each CSV phone in the
|
||||
* caller's contacts table; insert any that don't exist; return the
|
||||
* resolved set.
|
||||
*
|
||||
* Pre-existing implementation synthesized `csv-N` strings as
|
||||
* contact_id, which failed the UUID cast on insert — every CSV
|
||||
* broadcast silently created zero recipients.
|
||||
*/
|
||||
async function upsertCsvContacts(
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
csvRows: { phone: string; name?: string }[],
|
||||
): Promise<Contact[]> {
|
||||
if (csvRows.length === 0) return [];
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const user = session?.user;
|
||||
if (!user) {
|
||||
throw new Error('You are not signed in.');
|
||||
}
|
||||
if (!accountId) {
|
||||
throw new Error('Your profile is not linked to an account.');
|
||||
}
|
||||
|
||||
// De-duplicate by phone within the CSV (users can paste duplicates).
|
||||
const uniqueByPhone = new Map<string, { phone: string; name?: string }>();
|
||||
for (const row of csvRows) {
|
||||
if (row.phone) uniqueByPhone.set(row.phone, row);
|
||||
}
|
||||
const phones = [...uniqueByPhone.keys()];
|
||||
|
||||
// Single round-trip lookup of existing contacts by phone.
|
||||
const { data: existing, error: lookupErr } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.in('phone', phones);
|
||||
if (lookupErr) {
|
||||
throw new Error(`Failed to look up CSV contacts: ${lookupErr.message}`);
|
||||
}
|
||||
|
||||
const byPhone = new Map<string, Contact>();
|
||||
for (const c of (existing ?? []) as Contact[]) {
|
||||
if (c.phone) byPhone.set(c.phone, c);
|
||||
}
|
||||
|
||||
// Insert only missing contacts, in one batch per 200 rows (PostgREST
|
||||
// has a default payload cap — 200 keeps individual requests small).
|
||||
const missing = phones
|
||||
.filter((p) => !byPhone.has(p))
|
||||
.map((phone) => ({
|
||||
user_id: user.id,
|
||||
account_id: accountId,
|
||||
phone,
|
||||
name: uniqueByPhone.get(phone)?.name ?? null,
|
||||
}));
|
||||
|
||||
const INSERT_CHUNK = 200;
|
||||
for (let i = 0; i < missing.length; i += INSERT_CHUNK) {
|
||||
const chunk = missing.slice(i, i + INSERT_CHUNK);
|
||||
const { data: inserted, error: insertErr } = await supabase
|
||||
.from('contacts')
|
||||
.insert(chunk)
|
||||
.select();
|
||||
if (insertErr) {
|
||||
throw new Error(`Failed to create CSV contacts: ${insertErr.message}`);
|
||||
}
|
||||
for (const c of (inserted ?? []) as Contact[]) {
|
||||
if (c.phone) byPhone.set(c.phone, c);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve input order so analytics roughly matches the CSV order.
|
||||
return phones
|
||||
.map((p) => byPhone.get(p))
|
||||
.filter((c): c is Contact => Boolean(c));
|
||||
}
|
||||
|
||||
async function resolveCustomFieldAudience(
|
||||
supabase: ReturnType<typeof createClient>,
|
||||
filter: CustomFieldFilter,
|
||||
): Promise<Contact[]> {
|
||||
const { fieldId, operator, value } = filter;
|
||||
|
||||
// Build the WHERE clause for the operator. PostgREST supports
|
||||
// eq/neq/ilike via the query builder — use ilike with wildcards
|
||||
// for "contains" so the match is case-insensitive.
|
||||
let query = supabase
|
||||
.from('contact_custom_values')
|
||||
.select('contact_id')
|
||||
.eq('custom_field_id', fieldId);
|
||||
|
||||
if (operator === 'is') query = query.eq('value', value);
|
||||
else if (operator === 'is_not') query = query.neq('value', value);
|
||||
else if (operator === 'contains') query = query.ilike('value', `%${value}%`);
|
||||
|
||||
const { data: matches, error: matchErr } = await query;
|
||||
if (matchErr)
|
||||
throw new Error(`Custom-field filter failed: ${matchErr.message}`);
|
||||
|
||||
const contactIds = [...new Set((matches ?? []).map((m) => m.contact_id))];
|
||||
if (contactIds.length === 0) return [];
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.in('id', contactIds);
|
||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async function createAndSendBroadcast(payload: BroadcastPayload): Promise<string> {
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
// ── Step 0: Resolve current user ──────────────────────────────
|
||||
// broadcasts.user_id is NOT NULL + guarded by RLS
|
||||
// (auth.uid() = user_id). Without this, the INSERT below was
|
||||
// silently failing with 23502 / 42501 — the wizard would
|
||||
// no-op with no feedback.
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const user = session?.user;
|
||||
if (!user) {
|
||||
throw new Error('You are not signed in.');
|
||||
}
|
||||
if (!accountId) {
|
||||
throw new Error('Your profile is not linked to an account.');
|
||||
}
|
||||
|
||||
// ── Step 1: Resolve audience contacts ─────────────────────────
|
||||
setProgress(5);
|
||||
const contacts = await resolveAudience(payload.audience);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
throw new Error('No contacts found for this audience.');
|
||||
}
|
||||
|
||||
// ── Step 2: Create broadcast row ──────────────────────────────
|
||||
setProgress(10);
|
||||
const { data: broadcast, error: broadcastError } = await supabase
|
||||
.from('broadcasts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
account_id: accountId,
|
||||
name: payload.name,
|
||||
template_name: payload.template.name,
|
||||
template_language: payload.template.language ?? 'en_US',
|
||||
template_variables: payload.variables,
|
||||
audience_filter: {
|
||||
type: payload.audience.type,
|
||||
tagIds: payload.audience.tagIds,
|
||||
customField: payload.audience.customField,
|
||||
excludeTagIds: payload.audience.excludeTagIds,
|
||||
},
|
||||
status: 'sending',
|
||||
total_recipients: contacts.length,
|
||||
sent_count: 0,
|
||||
delivered_count: 0,
|
||||
read_count: 0,
|
||||
replied_count: 0,
|
||||
failed_count: 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (broadcastError || !broadcast) {
|
||||
throw new Error(
|
||||
`Failed to create broadcast: ${broadcastError?.message ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: Insert recipient rows ─────────────────────────────
|
||||
setProgress(20);
|
||||
const recipientRows = contacts.map((contact) => ({
|
||||
broadcast_id: broadcast.id,
|
||||
contact_id: contact.id,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < recipientRows.length; i += INSERT_BATCH_SIZE) {
|
||||
const batch = recipientRows.slice(i, i + INSERT_BATCH_SIZE);
|
||||
const { error: recipientError } = await supabase
|
||||
.from('broadcast_recipients')
|
||||
.insert(batch);
|
||||
if (recipientError) {
|
||||
// Previous impl logged and marched on — the broadcast then ran
|
||||
// with an incomplete recipient set, so webhook status updates
|
||||
// couldn't find some rows and the aggregate counts drifted.
|
||||
// Flip the broadcast to failed so the user sees the problem
|
||||
// immediately, then throw to abort the send loop.
|
||||
await supabase
|
||||
.from('broadcasts')
|
||||
.update({
|
||||
status: 'failed',
|
||||
failed_count: contacts.length,
|
||||
})
|
||||
.eq('id', broadcast.id);
|
||||
throw new Error(
|
||||
`Failed to insert recipient batch ${i / INSERT_BATCH_SIZE + 1}: ${recipientError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: Fetch recipients (joined contact) + preload custom values
|
||||
setProgress(30);
|
||||
const { data: recipients, error: recipientsFetchError } = await supabase
|
||||
.from('broadcast_recipients')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('broadcast_id', broadcast.id);
|
||||
|
||||
if (recipientsFetchError || !recipients) {
|
||||
throw new Error('Failed to fetch broadcast recipients');
|
||||
}
|
||||
|
||||
// One bulk fetch of custom values for every contact in this
|
||||
// broadcast, avoiding N+1 during the send loop.
|
||||
const contactIds = recipients
|
||||
.map((r) => r.contact?.id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const customValueIndex = await fetchCustomValueIndex(
|
||||
supabase,
|
||||
contactIds,
|
||||
);
|
||||
|
||||
let failedCount = 0;
|
||||
const totalRecipients = recipients.length;
|
||||
|
||||
// Media-header templates (image/video/document) require a media
|
||||
// URL on every send. Collected in the personalize step and applied
|
||||
// to all recipients; falls back to the template's stored URL on the
|
||||
// server when omitted.
|
||||
const headerType = payload.template.header_type;
|
||||
const isMediaHeader =
|
||||
headerType === 'image' ||
|
||||
headerType === 'video' ||
|
||||
headerType === 'document';
|
||||
const headerMediaUrl = payload.headerMediaUrl?.trim();
|
||||
const messageParams =
|
||||
isMediaHeader && headerMediaUrl ? { headerMediaUrl } : undefined;
|
||||
|
||||
for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) {
|
||||
const batch = recipients.slice(i, i + SEND_BATCH_SIZE);
|
||||
|
||||
const apiRecipients = batch
|
||||
.filter((r) => r.contact?.phone)
|
||||
.map((r) => ({
|
||||
phone: r.contact!.phone as string,
|
||||
params: r.contact
|
||||
? resolveVariables(
|
||||
payload.variables,
|
||||
r.contact,
|
||||
customValueIndex.get(r.contact.id),
|
||||
)
|
||||
: [],
|
||||
...(messageParams ? { messageParams } : {}),
|
||||
}));
|
||||
|
||||
if (apiRecipients.length === 0) continue;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp/broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipients: apiRecipients,
|
||||
template_name: payload.template.name,
|
||||
template_language: payload.template.language ?? 'en_US',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Broadcast API request failed');
|
||||
}
|
||||
|
||||
const resultsByPhone = new Map<string, BroadcastApiResult>();
|
||||
for (const r of (data.results ?? []) as BroadcastApiResult[]) {
|
||||
resultsByPhone.set(r.phone, r);
|
||||
}
|
||||
|
||||
for (const recipient of batch) {
|
||||
const phone = recipient.contact?.phone;
|
||||
const result = phone ? resultsByPhone.get(phone) : undefined;
|
||||
|
||||
if (!result) {
|
||||
failedCount++;
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: 'No phone number on contact',
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.status === 'sent') {
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
whatsapp_message_id: result.whatsapp_message_id ?? null,
|
||||
error_message: null,
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
} else {
|
||||
failedCount++;
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: result.error ?? 'Unknown error',
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
for (const recipient of batch) {
|
||||
failedCount++;
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
}
|
||||
}
|
||||
|
||||
const progressPct =
|
||||
30 + Math.round(((i + batch.length) / totalRecipients) * 60);
|
||||
setProgress(progressPct);
|
||||
|
||||
if (i + SEND_BATCH_SIZE < recipients.length) {
|
||||
await sleep(SEND_BATCH_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: Finalize status ───────────────────────────────────
|
||||
// Aggregate counts are maintained by the DB trigger (migration
|
||||
// 003); we only flip the final status here.
|
||||
setProgress(95);
|
||||
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
|
||||
await supabase
|
||||
.from('broadcasts')
|
||||
.update({ status: finalStatus })
|
||||
.eq('id', broadcast.id);
|
||||
|
||||
setProgress(100);
|
||||
return broadcast.id;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { createAndSendBroadcast, isProcessing, progress };
|
||||
}
|
||||
67
wacrm/src/hooks/use-can.ts
Normal file
67
wacrm/src/hooks/use-can.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
canDeleteAccount,
|
||||
canEditSettings,
|
||||
canManageMembers,
|
||||
canSendMessages,
|
||||
canTransferOwnership,
|
||||
canViewOnly,
|
||||
} from "@/lib/auth/roles";
|
||||
|
||||
/**
|
||||
* Typed action keys for `useCan`. Adding a capability = one new
|
||||
* entry here + one new case in the switch below + (usually) one
|
||||
* new predicate in `@/lib/auth/roles`. Keeping the list closed
|
||||
* lets the compiler catch typos at every call site.
|
||||
*/
|
||||
export type CanAction =
|
||||
| "manage-members"
|
||||
| "edit-settings"
|
||||
| "send-messages"
|
||||
| "view-only"
|
||||
| "delete-account"
|
||||
| "transfer-ownership";
|
||||
|
||||
/**
|
||||
* Inline alternative to `<RequireRole>` for places that need a
|
||||
* boolean rather than a render conditional — typically disabled-
|
||||
* state on buttons, the readOnly flag on inputs, or controlling
|
||||
* tooltip copy ("Read-only" vs the action label).
|
||||
*
|
||||
* Returns `false` while `profileLoading` is true so transient
|
||||
* "you can!" flashes never appear to under-privileged users.
|
||||
*
|
||||
* Example:
|
||||
* const canEdit = useCan("edit-settings");
|
||||
* <Button disabled={!canEdit} title={canEdit ? "Save" : "Read-only"} />
|
||||
*/
|
||||
export function useCan(action: CanAction): boolean {
|
||||
const { profileLoading, accountRole } = useAuth();
|
||||
if (profileLoading || !accountRole) return false;
|
||||
|
||||
switch (action) {
|
||||
case "manage-members":
|
||||
return canManageMembers(accountRole);
|
||||
case "edit-settings":
|
||||
return canEditSettings(accountRole);
|
||||
case "send-messages":
|
||||
return canSendMessages(accountRole);
|
||||
case "view-only":
|
||||
return canViewOnly(accountRole);
|
||||
case "delete-account":
|
||||
return canDeleteAccount(accountRole);
|
||||
case "transfer-ownership":
|
||||
return canTransferOwnership(accountRole);
|
||||
default: {
|
||||
// Exhaustiveness check — adding a new `CanAction` without a
|
||||
// case here fails the typecheck because TS narrows `action`
|
||||
// to `never` in this branch. The runtime throw is unreachable
|
||||
// for valid inputs; it only fires if someone bypasses the
|
||||
// type system at the call site (e.g. with a wrong-typed cast).
|
||||
const _exhaustive: never = action;
|
||||
throw new Error(`Unknown CanAction: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
wacrm/src/hooks/use-presence.ts
Normal file
171
wacrm/src/hooks/use-presence.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
derivePresence,
|
||||
type PresenceRow,
|
||||
type PresenceStatus,
|
||||
type StoredPresence,
|
||||
} from "@/lib/presence";
|
||||
|
||||
// How often the viewer re-derives presence locally. The online→offline
|
||||
// transition fires NO database event (it's just the clock passing the
|
||||
// staleness threshold), so without this tick a member who closes their
|
||||
// tab would appear online forever. ~15s keeps "offline" responsive
|
||||
// without busy-spinning.
|
||||
const RE_DERIVE_MS = 15_000;
|
||||
|
||||
type PresenceMap = Map<string, PresenceRow>;
|
||||
|
||||
interface UsePresenceResult {
|
||||
/** Derived status for one member (defaults to offline if unseen). */
|
||||
getPresence: (userId: string) => PresenceStatus;
|
||||
/** Raw row for tooltips ("last seen …"). */
|
||||
getRow: (userId: string) => PresenceRow | undefined;
|
||||
/**
|
||||
* The clock value the hook is currently deriving against. Pass this
|
||||
* to `presenceLabel` / `formatLastSeen` so labels stay in lockstep
|
||||
* with the dots (both advance on the same ~15s re-derive tick).
|
||||
*/
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live presence for every member of the caller's account. Reads the
|
||||
* `member_presence` table (RLS-scoped to the account), subscribes to
|
||||
* Realtime changes, and re-derives "offline" on a local timer.
|
||||
*
|
||||
* Account comes from useAuth; pass `enabled: false` to opt a consumer
|
||||
* out (e.g. while a parent sheet is closed).
|
||||
*/
|
||||
export function usePresence(enabled = true): UsePresenceResult {
|
||||
const { accountId } = useAuth();
|
||||
|
||||
// Presence rows keyed by user_id, held in immutable state — each
|
||||
// update replaces the Map so React renders and the derived getters
|
||||
// recompute. No ref/version dance needed.
|
||||
const [rows, setRows] = useState<PresenceMap>(() => new Map());
|
||||
|
||||
// `now` ticks so derivePresence re-evaluates staleness over time.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const active = enabled && !!accountId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !accountId) return;
|
||||
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
|
||||
const applyRow = (row: {
|
||||
user_id: string;
|
||||
status: StoredPresence;
|
||||
last_seen_at: string;
|
||||
}) => {
|
||||
setRows((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(row.user_id, {
|
||||
status: row.status,
|
||||
last_seen_at: row.last_seen_at,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Subscribe FIRST, then snapshot. The snapshot MERGES into whatever
|
||||
// Realtime has already delivered (keeping the newer last_seen_at)
|
||||
// rather than replacing the map — so an event that lands while the
|
||||
// fetch is in flight isn't clobbered by a staler snapshot row.
|
||||
const channel: RealtimeChannel = supabase
|
||||
.channel(`presence:${accountId}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "*",
|
||||
schema: "public",
|
||||
table: "member_presence",
|
||||
filter: `account_id=eq.${accountId}`,
|
||||
},
|
||||
(payload) => {
|
||||
if (payload.eventType === "DELETE") {
|
||||
const old = payload.old as { user_id?: string };
|
||||
if (!old.user_id) return;
|
||||
setRows((prev) => {
|
||||
if (!prev.has(old.user_id!)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(old.user_id!);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyRow(
|
||||
payload.new as {
|
||||
user_id: string;
|
||||
status: StoredPresence;
|
||||
last_seen_at: string;
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
supabase
|
||||
.from("member_presence")
|
||||
.select("user_id, status, last_seen_at")
|
||||
.eq("account_id", accountId)
|
||||
.then(({ data, error }) => {
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
console.error("[usePresence] initial fetch error:", error.message);
|
||||
return;
|
||||
}
|
||||
setRows((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const r of data ?? []) {
|
||||
const userId = r.user_id as string;
|
||||
const incoming: PresenceRow = {
|
||||
status: r.status as StoredPresence,
|
||||
last_seen_at: r.last_seen_at as string,
|
||||
};
|
||||
const existing = next.get(userId);
|
||||
// A live event that arrived first must win over a staler
|
||||
// snapshot row.
|
||||
if (
|
||||
!existing ||
|
||||
new Date(incoming.last_seen_at) >= new Date(existing.last_seen_at)
|
||||
) {
|
||||
next.set(userId, incoming);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
const tick = setInterval(() => setNow(Date.now()), RE_DERIVE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(tick);
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [active, accountId]);
|
||||
|
||||
const getRow = useCallback(
|
||||
(userId: string): PresenceRow | undefined => rows.get(userId),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const getPresence = useCallback(
|
||||
(userId: string): PresenceStatus => {
|
||||
const row = rows.get(userId);
|
||||
return derivePresence(row?.status, row?.last_seen_at, now);
|
||||
},
|
||||
[rows, now],
|
||||
);
|
||||
|
||||
return { getPresence, getRow, now };
|
||||
}
|
||||
94
wacrm/src/hooks/use-realtime.ts
Normal file
94
wacrm/src/hooks/use-realtime.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Message, Conversation } from "@/types";
|
||||
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||
|
||||
interface RealtimeEvent<T> {
|
||||
eventType: "INSERT" | "UPDATE" | "DELETE";
|
||||
new: T;
|
||||
old: Partial<T>;
|
||||
}
|
||||
|
||||
interface UseRealtimeOptions {
|
||||
channelName: string;
|
||||
onMessageEvent?: (event: RealtimeEvent<Message>) => void;
|
||||
onConversationEvent?: (event: RealtimeEvent<Conversation>) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useRealtime({
|
||||
channelName,
|
||||
onMessageEvent,
|
||||
onConversationEvent,
|
||||
enabled = true,
|
||||
}: UseRealtimeOptions) {
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
// Store latest callbacks in refs to avoid re-subscribing when the
|
||||
// parent re-renders with fresh closures. Assigned inside an effect
|
||||
// so the mutation doesn't happen during render (React 19's refs
|
||||
// rule) — subscribers only read `.current` inside async Realtime
|
||||
// callbacks, which always run after the render that updates it.
|
||||
const onMessageRef = useRef(onMessageEvent);
|
||||
const onConversationRef = useRef(onConversationEvent);
|
||||
useEffect(() => {
|
||||
onMessageRef.current = onMessageEvent;
|
||||
onConversationRef.current = onConversationEvent;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
const channel = supabase
|
||||
.channel(channelName)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "messages" },
|
||||
(payload) => {
|
||||
onMessageRef.current?.({
|
||||
eventType: payload.eventType as RealtimeEvent<Message>["eventType"],
|
||||
new: payload.new as Message,
|
||||
old: payload.old as Partial<Message>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "conversations" },
|
||||
(payload) => {
|
||||
onConversationRef.current?.({
|
||||
eventType: payload.eventType as RealtimeEvent<Conversation>["eventType"],
|
||||
new: payload.new as Conversation,
|
||||
old: payload.old as Partial<Conversation>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
setIsConnected(status === "SUBSCRIBED");
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
channelRef.current = null;
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [channelName, enabled]);
|
||||
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (channelRef.current) {
|
||||
const supabase = createClient();
|
||||
supabase.removeChannel(channelRef.current);
|
||||
channelRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isConnected, unsubscribe };
|
||||
}
|
||||
156
wacrm/src/hooks/use-theme.tsx
Normal file
156
wacrm/src/hooks/use-theme.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_MODE,
|
||||
DEFAULT_THEME,
|
||||
MODE_STORAGE_KEY,
|
||||
STORAGE_KEY,
|
||||
isMode,
|
||||
isThemeId,
|
||||
type Mode,
|
||||
type ThemeId,
|
||||
} from "@/lib/themes";
|
||||
|
||||
/**
|
||||
* ThemeProvider — wraps the whole app, owns the two theming axes:
|
||||
* • `theme` — the accent color (`data-theme` on <html>)
|
||||
* • `mode` — light / dark (`data-mode` on <html>)
|
||||
* The two are independent, so any accent renders in either mode.
|
||||
*
|
||||
* The boot script in `src/app/layout.tsx` has already applied both
|
||||
* `data-theme` and `data-mode` before React hydrates, so by the time
|
||||
* this Provider mounts the page is already painted correctly. We just
|
||||
* read what's there and keep it in sync going forward.
|
||||
*
|
||||
* Persistence is localStorage only (device-scoped). A future
|
||||
* follow-up could mirror to `profiles.preferences` for cross-device
|
||||
* sync, but a per-device choice is also defensible — your phone may
|
||||
* deserve a different theme than your laptop.
|
||||
*/
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeId;
|
||||
setTheme: (next: ThemeId) => void;
|
||||
mode: Mode;
|
||||
setMode: (next: Mode) => void;
|
||||
toggleMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readInitialTheme(): ThemeId {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
// Whatever the boot script applied is the truth. Fall back to
|
||||
// localStorage / default if for some reason the attribute is missing
|
||||
// (e.g. someone bypassed the boot script in a custom layout).
|
||||
const fromAttr = document.documentElement.dataset.theme;
|
||||
if (isThemeId(fromAttr)) return fromAttr;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (isThemeId(stored)) return stored;
|
||||
} catch {
|
||||
// localStorage can throw in private-browsing / sandboxed contexts.
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
function readInitialMode(): Mode {
|
||||
if (typeof window === "undefined") return DEFAULT_MODE;
|
||||
const fromAttr = document.documentElement.dataset.mode;
|
||||
if (isMode(fromAttr)) return fromAttr;
|
||||
try {
|
||||
const stored = localStorage.getItem(MODE_STORAGE_KEY);
|
||||
if (isMode(stored)) return stored;
|
||||
} catch {
|
||||
// localStorage can throw in private-browsing / sandboxed contexts.
|
||||
}
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeId>(readInitialTheme);
|
||||
const [mode, setModeState] = useState<Mode>(readInitialMode);
|
||||
|
||||
const setTheme = useCallback((next: ThemeId) => {
|
||||
setThemeState(next);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = next;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Same private-browsing edge case as above; the in-memory state
|
||||
// still updates so the current tab works for the session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((next: Mode) => {
|
||||
setModeState(next);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.mode = next;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(MODE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Same private-browsing edge case as above.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setMode(mode === "dark" ? "light" : "dark");
|
||||
}, [mode, setMode]);
|
||||
|
||||
// Sync from other tabs — change theme or mode in tab A, tab B
|
||||
// catches up without a refresh.
|
||||
useEffect(() => {
|
||||
function onStorage(e: StorageEvent) {
|
||||
if (e.key === STORAGE_KEY) {
|
||||
if (isThemeId(e.newValue) && e.newValue !== theme) {
|
||||
setThemeState(e.newValue);
|
||||
document.documentElement.dataset.theme = e.newValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === MODE_STORAGE_KEY) {
|
||||
if (isMode(e.newValue) && e.newValue !== mode) {
|
||||
setModeState(e.newValue);
|
||||
document.documentElement.dataset.mode = e.newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [theme, mode]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, mode, setMode, toggleMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
// Fallback for components rendered outside the provider — return
|
||||
// no-op setters so callers don't crash. The boot script still
|
||||
// applied the right CSS attributes, so visually the page is fine.
|
||||
return {
|
||||
theme: DEFAULT_THEME,
|
||||
setTheme: () => {},
|
||||
mode: DEFAULT_MODE,
|
||||
setMode: () => {},
|
||||
toggleMode: () => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
74
wacrm/src/hooks/use-total-unread.ts
Normal file
74
wacrm/src/hooks/use-total-unread.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Conversation } from "@/types";
|
||||
|
||||
/**
|
||||
* Count of conversations with at least one unread inbound message for
|
||||
* the current user. Used by the sidebar to surface a green dot on the
|
||||
* Inbox nav entry when the user is elsewhere in the app.
|
||||
*
|
||||
* Lives on its own realtime channel (distinct from the inbox page's
|
||||
* "inbox-realtime") so both can coexist without sharing state.
|
||||
*/
|
||||
export function useTotalUnread(): number {
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Keep a live local mirror of {id: unread_count} so INSERT/UPDATE/DELETE
|
||||
// events can adjust the total in O(1) without refetching.
|
||||
const countsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
|
||||
// Initial load. RLS scopes this to the signed-in user automatically —
|
||||
// no explicit user_id filter needed here.
|
||||
(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("conversations")
|
||||
.select("id, unread_count");
|
||||
if (cancelled || error || !data) return;
|
||||
|
||||
const map = new Map<string, number>();
|
||||
let sum = 0;
|
||||
for (const row of data as { id: string; unread_count: number }[]) {
|
||||
const n = row.unread_count ?? 0;
|
||||
map.set(row.id, n);
|
||||
if (n > 0) sum += 1;
|
||||
}
|
||||
countsRef.current = map;
|
||||
setTotal(sum);
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel("total-unread-realtime")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "conversations" },
|
||||
(payload) => {
|
||||
const map = countsRef.current;
|
||||
if (payload.eventType === "DELETE") {
|
||||
const oldRow = payload.old as Partial<Conversation>;
|
||||
if (oldRow.id) map.delete(oldRow.id);
|
||||
} else {
|
||||
const row = payload.new as Conversation;
|
||||
map.set(row.id, row.unread_count ?? 0);
|
||||
}
|
||||
// Recompute — cheap, conversations per user stay small.
|
||||
let sum = 0;
|
||||
for (const n of map.values()) if (n > 0) sum += 1;
|
||||
setTotal(sum);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return total;
|
||||
}
|
||||
63
wacrm/src/hooks/use-unread-notifications.ts
Normal file
63
wacrm/src/hooks/use-unread-notifications.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Notification } from "@/types";
|
||||
|
||||
/**
|
||||
* Count of unread notifications for the current user. Used by the
|
||||
* sidebar to surface a badge on the Notifications nav entry.
|
||||
*
|
||||
* RLS on `notifications` already scopes every read to `auth.uid() =
|
||||
* user_id`, so no explicit filter is needed here — same pattern as
|
||||
* `useTotalUnread` for conversations.
|
||||
*/
|
||||
export function useUnreadNotifications(): number {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
// head:true skips fetching rows — we only need the `count`
|
||||
// supabase-js returns alongside the (empty) response body.
|
||||
const { count: unreadCount, error } = await supabase
|
||||
.from("notifications")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.is("read_at", null);
|
||||
if (cancelled || error) return;
|
||||
setCount(unreadCount ?? 0);
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel("notifications-unread-count")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "notifications" },
|
||||
(payload) => {
|
||||
if (payload.eventType === "INSERT") {
|
||||
const row = payload.new as Notification;
|
||||
if (!row.read_at) setCount((n) => n + 1);
|
||||
} else if (payload.eventType === "UPDATE") {
|
||||
// Updates here only ever set read_at (marking a notification
|
||||
// read). Derive purely from the new row so we don't rely on
|
||||
// payload.old columns, which require REPLICA IDENTITY FULL.
|
||||
const newRow = payload.new as Notification;
|
||||
if (newRow.read_at) setCount((n) => Math.max(0, n - 1));
|
||||
} else if (payload.eventType === "DELETE") {
|
||||
const oldRow = payload.old as Partial<Notification>;
|
||||
if (!oldRow.read_at) setCount((n) => Math.max(0, n - 1));
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user