498 lines
13 KiB
TypeScript
498 lines
13 KiB
TypeScript
import { getAuthenticatedUser } from "@/lib/auth/session";
|
|
import { getLocalDateForTimezone } from "@/lib/dates";
|
|
import { calculateDayOverviewSnapshot } from "@/lib/planning/day-overview";
|
|
import { buildActivitySuggestions } from "@/lib/planning/suggestions";
|
|
import type {
|
|
ActivityCategory,
|
|
CreateAdHocActivitySubmission,
|
|
CreateActivitySubmission,
|
|
ActivityImpactLevel,
|
|
ActivityPriorityLevel,
|
|
PlanningPageData,
|
|
ActivityRecord,
|
|
ActivitySource,
|
|
ActivitiesForDateStatus,
|
|
ActivityStatus,
|
|
SkipReason,
|
|
UpdateActivityEvaluationSubmission,
|
|
UpdateActivityStatusSubmission,
|
|
} from "@/lib/planning/types";
|
|
import { ensureProfileBundleForCurrentUser } from "@/lib/profile/service";
|
|
import { createClient } from "@/lib/supabase/server";
|
|
|
|
type SupabaseServerClient = Awaited<ReturnType<typeof createClient>>;
|
|
|
|
type ActivityCategoryRow = {
|
|
id: string;
|
|
key: string;
|
|
label_nl: string;
|
|
sort_order: number;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
type SkipReasonRow = {
|
|
id: string;
|
|
key: string;
|
|
label_nl: string;
|
|
sort_order: number;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
};
|
|
|
|
type ActivityRow = {
|
|
id: string;
|
|
user_id: string;
|
|
activity_date: string;
|
|
source: ActivitySource;
|
|
status: ActivityStatus;
|
|
name: string;
|
|
category_id: string;
|
|
duration_minutes: number;
|
|
impact_level: ActivityImpactLevel;
|
|
priority_level: ActivityPriorityLevel;
|
|
skip_reason_id: string | null;
|
|
notes: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
const ACTIVITY_CATEGORY_COLUMNS =
|
|
"id, key, label_nl, sort_order, is_active, created_at";
|
|
const SKIP_REASON_COLUMNS =
|
|
"id, key, label_nl, sort_order, is_active, created_at";
|
|
const ACTIVITY_COLUMNS =
|
|
"id, user_id, activity_date, source, status, name, category_id, duration_minutes, impact_level, priority_level, skip_reason_id, notes, created_at, updated_at";
|
|
|
|
function mapActivityCategoryRow(row: ActivityCategoryRow): ActivityCategory {
|
|
return {
|
|
id: row.id,
|
|
key: row.key,
|
|
labelNl: row.label_nl,
|
|
sortOrder: row.sort_order,
|
|
isActive: row.is_active,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
function mapSkipReasonRow(row: SkipReasonRow): SkipReason {
|
|
return {
|
|
id: row.id,
|
|
key: row.key,
|
|
labelNl: row.label_nl,
|
|
sortOrder: row.sort_order,
|
|
isActive: row.is_active,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
function mapActivityRow(row: ActivityRow): ActivityRecord {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
activityDate: row.activity_date,
|
|
source: row.source,
|
|
status: row.status,
|
|
name: row.name,
|
|
categoryId: row.category_id,
|
|
durationMinutes: row.duration_minutes,
|
|
impactLevel: row.impact_level,
|
|
priorityLevel: row.priority_level,
|
|
skipReasonId: row.skip_reason_id,
|
|
notes: row.notes,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function assertIsoDate(value: string) {
|
|
if (!ISO_DATE_PATTERN.test(value)) {
|
|
throw new Error("Ongeldige plandatum. Gebruik het formaat YYYY-MM-DD.");
|
|
}
|
|
}
|
|
|
|
async function readActivitiesByDate(
|
|
supabase: SupabaseServerClient,
|
|
userId: string,
|
|
activityDate: string,
|
|
): Promise<ActivityRecord[]> {
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.select(ACTIVITY_COLUMNS)
|
|
.eq("user_id", userId)
|
|
.eq("activity_date", activityDate)
|
|
.order("created_at", { ascending: true });
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteiten konden niet worden geladen: ${error.message}`);
|
|
}
|
|
|
|
return (data ?? []).map(mapActivityRow);
|
|
}
|
|
|
|
async function readRecentActivitiesForSuggestions(
|
|
supabase: SupabaseServerClient,
|
|
userId: string,
|
|
currentActivityDate: string,
|
|
): Promise<ActivityRecord[]> {
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.select(ACTIVITY_COLUMNS)
|
|
.eq("user_id", userId)
|
|
.neq("activity_date", currentActivityDate)
|
|
.order("updated_at", { ascending: false })
|
|
.limit(60);
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteitensuggesties konden niet worden geladen: ${error.message}`);
|
|
}
|
|
|
|
return (data ?? []).map(mapActivityRow);
|
|
}
|
|
|
|
async function assertCategoryExists(
|
|
supabase: SupabaseServerClient,
|
|
categoryId: string,
|
|
): Promise<void> {
|
|
const { data, error } = await supabase
|
|
.from("activity_categories")
|
|
.select("id")
|
|
.eq("id", categoryId)
|
|
.eq("is_active", true)
|
|
.maybeSingle();
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteitcategorie kon niet worden gevalideerd: ${error.message}`);
|
|
}
|
|
|
|
if (!data) {
|
|
throw new Error("Ongeldige activiteitcategorie.");
|
|
}
|
|
}
|
|
|
|
async function assertSkipReasonExists(
|
|
supabase: SupabaseServerClient,
|
|
skipReasonId: string,
|
|
): Promise<void> {
|
|
const { data, error } = await supabase
|
|
.from("skip_reasons")
|
|
.select("id")
|
|
.eq("id", skipReasonId)
|
|
.eq("is_active", true)
|
|
.maybeSingle();
|
|
|
|
if (error) {
|
|
throw new Error(`Skip-reden kon niet worden gevalideerd: ${error.message}`);
|
|
}
|
|
|
|
if (!data) {
|
|
throw new Error("Ongeldige skip-reden.");
|
|
}
|
|
}
|
|
|
|
export async function listActivityCategories(): Promise<ActivityCategory[]> {
|
|
const supabase = await createClient();
|
|
const { data, error } = await supabase
|
|
.from("activity_categories")
|
|
.select(ACTIVITY_CATEGORY_COLUMNS)
|
|
.order("sort_order", { ascending: true });
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteitcategorieën konden niet worden geladen: ${error.message}`);
|
|
}
|
|
|
|
return (data ?? []).map(mapActivityCategoryRow);
|
|
}
|
|
|
|
export async function listSkipReasons(): Promise<SkipReason[]> {
|
|
const supabase = await createClient();
|
|
const { data, error } = await supabase
|
|
.from("skip_reasons")
|
|
.select(SKIP_REASON_COLUMNS)
|
|
.order("sort_order", { ascending: true });
|
|
|
|
if (error) {
|
|
throw new Error(`Skip-redenen konden niet worden geladen: ${error.message}`);
|
|
}
|
|
|
|
return (data ?? []).map(mapSkipReasonRow);
|
|
}
|
|
|
|
export async function getActivitiesForDateForCurrentUser(
|
|
activityDate: string,
|
|
): Promise<ActivityRecord[] | null> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
assertIsoDate(activityDate);
|
|
|
|
const supabase = await createClient();
|
|
return readActivitiesByDate(supabase, user.id, activityDate);
|
|
}
|
|
|
|
export async function getTodayActivitiesForCurrentUser(): Promise<ActivitiesForDateStatus | null> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
return null;
|
|
}
|
|
|
|
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
const activities = await readActivitiesByDate(supabase, user.id, activityDate);
|
|
|
|
return {
|
|
timezone: profileBundle.profile.timezone,
|
|
activityDate,
|
|
activities,
|
|
};
|
|
}
|
|
|
|
export async function getPlanningPageDataForCurrentUser(): Promise<PlanningPageData | null> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
return null;
|
|
}
|
|
|
|
const currentActivityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
|
|
const [categories, skipReasons, activitiesStatus, recentActivities] = await Promise.all([
|
|
listActivityCategories(),
|
|
listSkipReasons(),
|
|
getTodayActivitiesForCurrentUser(),
|
|
readRecentActivitiesForSuggestions(supabase, user.id, currentActivityDate),
|
|
]);
|
|
|
|
return {
|
|
timezone: profileBundle.profile.timezone,
|
|
activityDate:
|
|
activitiesStatus?.activityDate ?? currentActivityDate,
|
|
categories,
|
|
skipReasons,
|
|
activities: activitiesStatus?.activities ?? [],
|
|
suggestions: buildActivitySuggestions(recentActivities),
|
|
dayOverview: calculateDayOverviewSnapshot(activitiesStatus?.activities ?? []),
|
|
};
|
|
}
|
|
|
|
export async function createActivityForTodayForCurrentUser(
|
|
submission: CreateActivitySubmission,
|
|
): Promise<ActivityRecord> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
|
|
}
|
|
|
|
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
|
|
await assertCategoryExists(supabase, submission.categoryId);
|
|
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.insert({
|
|
user_id: user.id,
|
|
activity_date: activityDate,
|
|
source: "planned",
|
|
status: "planned",
|
|
name: submission.name,
|
|
category_id: submission.categoryId,
|
|
duration_minutes: submission.durationMinutes,
|
|
impact_level: submission.impactLevel,
|
|
priority_level: submission.priorityLevel,
|
|
skip_reason_id: null,
|
|
notes: null,
|
|
})
|
|
.select(ACTIVITY_COLUMNS)
|
|
.single();
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteit kon niet worden opgeslagen: ${error.message}`);
|
|
}
|
|
|
|
return mapActivityRow(data);
|
|
}
|
|
|
|
export async function createAdHocActivityForTodayForCurrentUser(
|
|
submission: CreateAdHocActivitySubmission,
|
|
): Promise<ActivityRecord> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
|
|
}
|
|
|
|
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
|
|
await assertCategoryExists(supabase, submission.categoryId);
|
|
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.insert({
|
|
user_id: user.id,
|
|
activity_date: activityDate,
|
|
source: "ad_hoc",
|
|
status: "completed",
|
|
name: submission.name,
|
|
category_id: submission.categoryId,
|
|
duration_minutes: submission.durationMinutes,
|
|
impact_level: submission.impactLevel,
|
|
priority_level: "normaal",
|
|
skip_reason_id: null,
|
|
notes: null,
|
|
})
|
|
.select(ACTIVITY_COLUMNS)
|
|
.single();
|
|
|
|
if (error) {
|
|
throw new Error(`Ongeplande activiteit kon niet worden opgeslagen: ${error.message}`);
|
|
}
|
|
|
|
return mapActivityRow(data);
|
|
}
|
|
|
|
export async function updateActivityStatusForTodayForCurrentUser(
|
|
submission: UpdateActivityStatusSubmission,
|
|
): Promise<ActivityRecord> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
|
|
}
|
|
|
|
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.update({
|
|
status: submission.status,
|
|
})
|
|
.eq("id", submission.activityId)
|
|
.eq("user_id", user.id)
|
|
.eq("activity_date", activityDate)
|
|
.select(ACTIVITY_COLUMNS)
|
|
.maybeSingle();
|
|
|
|
if (error) {
|
|
throw new Error(`Activiteitstatus kon niet worden opgeslagen: ${error.message}`);
|
|
}
|
|
|
|
if (!data) {
|
|
throw new Error("Ongeldige of niet-beschikbare activiteit.");
|
|
}
|
|
|
|
return mapActivityRow(data);
|
|
}
|
|
|
|
export async function updateActivityEvaluationForTodayForCurrentUser(
|
|
submission: UpdateActivityEvaluationSubmission,
|
|
): Promise<ActivityRecord> {
|
|
const user = await getAuthenticatedUser();
|
|
|
|
if (!user) {
|
|
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
|
|
}
|
|
|
|
const profileBundle = await ensureProfileBundleForCurrentUser();
|
|
|
|
if (!profileBundle) {
|
|
throw new Error("Profielbundle ontbreekt voor de huidige gebruiker.");
|
|
}
|
|
|
|
const activityDate = getLocalDateForTimezone(profileBundle.profile.timezone);
|
|
const supabase = await createClient();
|
|
|
|
const { data: existingActivity, error: existingActivityError } = await supabase
|
|
.from("activities")
|
|
.select(ACTIVITY_COLUMNS)
|
|
.eq("id", submission.activityId)
|
|
.eq("user_id", user.id)
|
|
.eq("activity_date", activityDate)
|
|
.maybeSingle();
|
|
|
|
if (existingActivityError) {
|
|
throw new Error(
|
|
`Activiteit voor evaluatie kon niet worden geladen: ${existingActivityError.message}`,
|
|
);
|
|
}
|
|
|
|
if (!existingActivity) {
|
|
throw new Error("Ongeldige of niet-beschikbare activiteit.");
|
|
}
|
|
|
|
let nextSkipReasonId: string | null = null;
|
|
let nextNotes: string | null = null;
|
|
|
|
if (existingActivity.status === "skipped") {
|
|
if (!submission.skipReasonId) {
|
|
throw new Error("Skip-reden is verplicht voor een overgeslagen activiteit.");
|
|
}
|
|
|
|
await assertSkipReasonExists(supabase, submission.skipReasonId);
|
|
nextSkipReasonId = submission.skipReasonId;
|
|
nextNotes = submission.notes;
|
|
} else if (existingActivity.status === "adjusted") {
|
|
if (!submission.notes) {
|
|
throw new Error("Toelichting is verplicht voor een aangepaste activiteit.");
|
|
}
|
|
|
|
nextNotes = submission.notes;
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from("activities")
|
|
.update({
|
|
skip_reason_id: nextSkipReasonId,
|
|
notes: nextNotes,
|
|
})
|
|
.eq("id", existingActivity.id)
|
|
.eq("user_id", user.id)
|
|
.eq("activity_date", activityDate)
|
|
.select(ACTIVITY_COLUMNS)
|
|
.single();
|
|
|
|
if (error) {
|
|
throw new Error(`Evaluatiecontext kon niet worden opgeslagen: ${error.message}`);
|
|
}
|
|
|
|
return mapActivityRow(data);
|
|
}
|