diff --git a/app/auth/membership-confirmation/page.tsx b/app/auth/membership-confirmation/page.tsx index f4ae5fe..e93a5ae 100644 --- a/app/auth/membership-confirmation/page.tsx +++ b/app/auth/membership-confirmation/page.tsx @@ -5,7 +5,7 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { withI18n } from '~/lib/i18n/with-i18n'; import { - PAGE_VIEW_ACTION, + PageViewAction, createPageViewLog, } from '~/lib/services/audit/pageView.service'; @@ -23,7 +23,7 @@ async function MembershipConfirmation() { } await createPageViewLog({ accountId: user.id, - action: PAGE_VIEW_ACTION.REGISTRATION_SUCCESS, + action: PageViewAction.REGISTRATION_SUCCESS, }); return ; diff --git a/app/doctor/analysis/[id]/page.tsx b/app/doctor/analysis/[id]/page.tsx index 5a2263c..aac793c 100644 --- a/app/doctor/analysis/[id]/page.tsx +++ b/app/doctor/analysis/[id]/page.tsx @@ -3,6 +3,11 @@ import { cache } from 'react'; import { getAnalysisResultsForDoctor } from '@kit/doctor/services/doctor-analysis.service'; import { PageBody, PageHeader } from '@kit/ui/page'; +import { + DoctorPageViewAction, + createDoctorPageViewLog, +} from '~/lib/services/audit/doctorPageView.service'; + import AnalysisView from '../../_components/analysis-view'; import { DoctorGuard } from '../../_components/doctor-guard'; @@ -20,6 +25,14 @@ async function AnalysisPage({ return null; } + if (analysisResultDetails) { + await createDoctorPageViewLog({ + action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS, + recordKey: id, + dataOwnerUserId: analysisResultDetails.patient.userId, + }); + } + return ( <> diff --git a/app/doctor/completed-jobs/page.tsx b/app/doctor/completed-jobs/page.tsx index 1035fff..1b48f90 100644 --- a/app/doctor/completed-jobs/page.tsx +++ b/app/doctor/completed-jobs/page.tsx @@ -1,10 +1,19 @@ +import { getUserDoneResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions'; import { PageBody, PageHeader } from '@kit/ui/page'; -import { getUserDoneResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions'; -import ResultsTableWrapper from '../_components/results-table-wrapper'; +import { + DoctorPageViewAction, + createDoctorPageViewLog, +} from '~/lib/services/audit/doctorPageView.service'; + import { DoctorGuard } from '../_components/doctor-guard'; +import ResultsTableWrapper from '../_components/results-table-wrapper'; async function CompletedJobsPage() { + await createDoctorPageViewLog({ + action: DoctorPageViewAction.VIEW_DONE_JOBS, + }); + return ( <> diff --git a/app/doctor/my-jobs/page.tsx b/app/doctor/my-jobs/page.tsx index 6fdfc78..0f3ad87 100644 --- a/app/doctor/my-jobs/page.tsx +++ b/app/doctor/my-jobs/page.tsx @@ -1,9 +1,19 @@ import { getUserInProgressResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions'; import { PageBody, PageHeader } from '@kit/ui/page'; -import ResultsTableWrapper from '../_components/results-table-wrapper'; + +import { + DoctorPageViewAction, + createDoctorPageViewLog, +} from '~/lib/services/audit/doctorPageView.service'; + import { DoctorGuard } from '../_components/doctor-guard'; +import ResultsTableWrapper from '../_components/results-table-wrapper'; async function MyReviewsPage() { + await createDoctorPageViewLog({ + action: DoctorPageViewAction.VIEW_OWN_JOBS, + }); + return ( <> diff --git a/app/doctor/open-jobs/page.tsx b/app/doctor/open-jobs/page.tsx index b1abf22..9a370c2 100644 --- a/app/doctor/open-jobs/page.tsx +++ b/app/doctor/open-jobs/page.tsx @@ -1,10 +1,19 @@ import { getOpenResponsesAction } from '@kit/doctor/actions/table-data-fetching-actions'; import { PageBody, PageHeader } from '@kit/ui/page'; +import { + DoctorPageViewAction, + createDoctorPageViewLog, +} from '~/lib/services/audit/doctorPageView.service'; + import { DoctorGuard } from '../_components/doctor-guard'; import ResultsTableWrapper from '../_components/results-table-wrapper'; async function OpenJobsPage() { + await createDoctorPageViewLog({ + action: DoctorPageViewAction.VIEW_OPEN_JOBS, + }); + return ( <> diff --git a/app/doctor/page.tsx b/app/doctor/page.tsx index 90ca14b..5edc7db 100644 --- a/app/doctor/page.tsx +++ b/app/doctor/page.tsx @@ -1,8 +1,18 @@ import { PageBody, PageHeader } from '@kit/ui/page'; + +import { + DoctorPageViewAction, + createDoctorPageViewLog, +} from '~/lib/services/audit/doctorPageView.service'; + import Dashboard from './_components/doctor-dashboard'; import { DoctorGuard } from './_components/doctor-guard'; async function DoctorPage() { + await createDoctorPageViewLog({ + action: DoctorPageViewAction.VIEW_DASHBOARD, + }); + return ( <> diff --git a/app/home/(user)/(dashboard)/analysis-results/page.tsx b/app/home/(user)/(dashboard)/analysis-results/page.tsx index 1ac3d5e..f744b88 100644 --- a/app/home/(user)/(dashboard)/analysis-results/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/page.tsx @@ -13,7 +13,7 @@ import { pathsConfig } from '@kit/shared/config'; import { getAnalysisElements } from '~/lib/services/analysis-element.service'; import { - PAGE_VIEW_ACTION, + PageViewAction, createPageViewLog, } from '~/lib/services/audit/pageView.service'; import { AnalysisOrder, getAnalysisOrders } from '~/lib/services/order.service'; @@ -50,7 +50,7 @@ async function AnalysisResultsPage() { await createPageViewLog({ accountId: account.id, - action: PAGE_VIEW_ACTION.VIEW_ANALYSIS_RESULTS, + action: PageViewAction.VIEW_ANALYSIS_RESULTS, }); const getAnalysisElementIds = (analysisOrders: AnalysisOrder[]) => [ diff --git a/app/home/(user)/(dashboard)/order-analysis/page.tsx b/app/home/(user)/(dashboard)/order-analysis/page.tsx index 8e0f179..1736dc9 100644 --- a/app/home/(user)/(dashboard)/order-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis/page.tsx @@ -6,7 +6,7 @@ import { Trans } from '@kit/ui/trans'; import { HomeLayoutPageHeader } from '../../_components/home-page-header'; import { loadAnalyses } from '../../_lib/server/load-analyses'; import OrderAnalysesCards from '../../_components/order-analyses-cards'; -import { createPageViewLog, PAGE_VIEW_ACTION } from '~/lib/services/audit/pageView.service'; +import { createPageViewLog, PageViewAction } from '~/lib/services/audit/pageView.service'; import { loadCurrentUserAccount } from '../../_lib/server/load-user-account'; export const generateMetadata = async () => { @@ -27,7 +27,7 @@ async function OrderAnalysisPage() { await createPageViewLog({ accountId: account.id, - action: PAGE_VIEW_ACTION.VIEW_ORDER_ANALYSIS, + action: PageViewAction.VIEW_ORDER_ANALYSIS, }); return ( diff --git a/app/home/[account]/page.tsx b/app/home/[account]/page.tsx index f3b9914..8bc2ade 100644 --- a/app/home/[account]/page.tsx +++ b/app/home/[account]/page.tsx @@ -12,7 +12,7 @@ import { PageBody } from '@kit/ui/page'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; import { - PAGE_VIEW_ACTION, + PageViewAction, createPageViewLog, } from '~/lib/services/audit/pageView.service'; @@ -46,7 +46,7 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { use( createPageViewLog({ accountId: teamAccount.id, - action: PAGE_VIEW_ACTION.VIEW_TEAM_ACCOUNT_DASHBOARD, + action: PageViewAction.VIEW_TEAM_ACCOUNT_DASHBOARD, }), ); diff --git a/lib/services/audit/doctorPageView.service.ts b/lib/services/audit/doctorPageView.service.ts new file mode 100644 index 0000000..36c40a5 --- /dev/null +++ b/lib/services/audit/doctorPageView.service.ts @@ -0,0 +1,46 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +export enum DoctorPageViewAction { + VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS', + VIEW_DASHBOARD = 'VIEW_DASHBOARD', + VIEW_OPEN_JOBS = 'VIEW_OPEN_JOBS', + VIEW_OWN_JOBS = 'VIEW_OWN_JOBS', + VIEW_DONE_JOBS = 'VIEW_DONE_JOBS', +} + +export const createDoctorPageViewLog = async ({ + action, + recordKey, + dataOwnerUserId, +}: { + action: DoctorPageViewAction; + recordKey?: string; + dataOwnerUserId?: string; +}) => { + try { + const supabase = getSupabaseServerClient(); + + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError || !user) { + console.error('No authenticated user found; skipping audit insert'); + return; + } + + await supabase + .schema('audit') + .from('doctor_page_views') + .insert({ + viewer_user_id: user.id, + data_owner_user_id: dataOwnerUserId, + viewed_record_key: recordKey, + action, + }) + .throwOnError(); + } catch (error) { + console.error('Failed to insert doctor page view log', error); + } +}; diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts index 92ce10e..f4e70f6 100644 --- a/lib/services/audit/pageView.service.ts +++ b/lib/services/audit/pageView.service.ts @@ -1,6 +1,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; -export enum PAGE_VIEW_ACTION { +export enum PageViewAction { VIEW_ANALYSIS_RESULTS = 'VIEW_ANALYSIS_RESULTS', REGISTRATION_SUCCESS = 'REGISTRATION_SUCCESS', VIEW_ORDER_ANALYSIS = 'VIEW_ORDER_ANALYSIS', @@ -12,7 +12,7 @@ export const createPageViewLog = async ({ action, }: { accountId: string; - action: PAGE_VIEW_ACTION; + action: PageViewAction; }) => { try { const supabase = getSupabaseServerClient(); diff --git a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts index d857aee..47a4aac 100644 --- a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts +++ b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts @@ -41,6 +41,7 @@ export const selectJobAction = doctorAction( } catch (e) { logger.error('Failed to select job', e); if (e instanceof Error) { + revalidateDoctorAnalysis(); return { success: false, reason: diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 10ce07d..4b3528b 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -42,6 +42,33 @@ export type Database = { } Relationships: [] } + doctor_page_views: { + Row: { + action: Database["audit"]["Enums"]["doctor_page_view_action"] + created_at: string + data_owner_user_id: string | null + id: number + viewed_record_key: string | null + viewer_user_id: string + } + Insert: { + action: Database["audit"]["Enums"]["doctor_page_view_action"] + created_at?: string + data_owner_user_id?: string | null + id?: number + viewed_record_key?: string | null + viewer_user_id: string + } + Update: { + action?: Database["audit"]["Enums"]["doctor_page_view_action"] + created_at?: string + data_owner_user_id?: string | null + id?: number + viewed_record_key?: string | null + viewer_user_id?: string + } + Relationships: [] + } log_entries: { Row: { changed_at: string @@ -182,6 +209,12 @@ export type Database = { [_ in never]: never } Enums: { + doctor_page_view_action: + | "VIEW_ANALYSIS_RESULTS" + | "VIEW_DASHBOARD" + | "VIEW_OPEN_JOBS" + | "VIEW_OWN_JOBS" + | "VIEW_DONE_JOBS" request_status: "SUCCESS" | "FAIL" sync_status: "SUCCESS" | "FAIL" } @@ -1778,7 +1811,6 @@ export type Database = { primary_owner_user_id: string name: string email: string - personal_code: string picture_url: string created_at: string updated_at: string @@ -7887,6 +7919,13 @@ export type CompositeTypes< export const Constants = { audit: { Enums: { + doctor_page_view_action: [ + "VIEW_ANALYSIS_RESULTS", + "VIEW_DASHBOARD", + "VIEW_OPEN_JOBS", + "VIEW_OWN_JOBS", + "VIEW_DONE_JOBS", + ], request_status: ["SUCCESS", "FAIL"], sync_status: ["SUCCESS", "FAIL"], }, diff --git a/supabase/migrations/20250826083609_add_doctor_audit_tables.sql b/supabase/migrations/20250826083609_add_doctor_audit_tables.sql new file mode 100644 index 0000000..4c1e633 --- /dev/null +++ b/supabase/migrations/20250826083609_add_doctor_audit_tables.sql @@ -0,0 +1,85 @@ + +create type audit.doctor_page_view_action as ENUM('VIEW_ANALYSIS_RESULTS','VIEW_DASHBOARD','VIEW_OPEN_JOBS','VIEW_OWN_JOBS','VIEW_DONE_JOBS'); + +create table audit.doctor_page_views ( + "id" bigint generated by default as identity not null, + "viewer_user_id" uuid references auth.users (id) not null, + "data_owner_user_id" uuid references auth.users (id), + "viewed_record_key" text, + "action" audit.doctor_page_view_action not null, + "created_at" timestamp with time zone not null default now() +); + +grant usage on schema audit to authenticated; +grant select, insert, update, delete on table audit.doctor_page_views to authenticated; + +alter table "audit"."page_views" enable row level security; + +create policy "insert_own" +on audit.doctor_page_views +as permissive +for insert +to authenticated +with check (auth.uid() = viewer_user_id); + +CREATE OR REPLACE FUNCTION medreport.log_doctor_analysis_feedback_changes() +RETURNS TRIGGER AS $$ +DECLARE + current_user_id uuid; + current_user_role text; + operation_type text; +BEGIN + begin + current_user_id := auth.uid(); + current_user_role := auth.jwt() ->> 'role'; + end; + IF (OLD.doctor_user_id IS DISTINCT FROM NEW.doctor_user_id) THEN + operation_type := CASE + WHEN NEW.doctor_user_id IS NULL THEN 'UNSELECT_JOB' + ELSE 'SELECT_JOB' + END; + ELSIF (NEW.status = 'DRAFT' OR (OLD.status IS DISTINCT FROM NEW.status AND NEW.status = 'COMPLETED')) THEN + operation_type := CASE + WHEN NEW.status = 'DRAFT' THEN 'UPDATED_DRAFT' + WHEN NEW.status = 'COMPLETED' THEN 'PUBLISHED_SUMMARY' + ELSE NULL + END; + ELSE + operation_type := NULL; + END IF; + + IF operation_type IS NOT NULL THEN + INSERT INTO audit.log_entries ( + schema_name, + table_name, + record_key, + operation, + changed_by, + changed_by_role, + changed_at + ) + VALUES ( + 'medreport', + 'doctor_analysis_feedback', + NEW.id::text, + operation_type, + current_user_id, + current_user_role, + NOW() + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS doctor_analysis_feedback_audit_trigger ON medreport.doctor_analysis_feedback; + +CREATE TRIGGER doctor_analysis_feedback_audit_trigger + AFTER UPDATE ON medreport.doctor_analysis_feedback + FOR EACH ROW + EXECUTE FUNCTION medreport.log_doctor_analysis_feedback_changes(); + +GRANT EXECUTE ON FUNCTION medreport.log_doctor_analysis_feedback_changes() TO authenticated; + +alter table audit.doctor_page_views enable row level security;