From 770eafcb626c0ec35649eb78d696acd95f112fb5 Mon Sep 17 00:00:00 2001 From: cade Date: Thu, 11 Jul 2024 23:11:51 -0600 Subject: [PATCH] timeline and various ui updates --- app/(pages)/(with-nav)/layout.tsx | 4 +- .../(with-nav)/subjects/(list)/layout.tsx | 32 ++- app/_components/event-comment.tsx | 14 +- app/_components/event-comments.tsx | 3 + app/_components/event-menu.tsx | 46 ++++ app/_components/event-page.tsx | 33 ++- app/_components/module-card.tsx | 9 +- app/_components/module-form-section.tsx | 28 ++- app/_components/select.tsx | 5 +- app/_components/session-form.tsx | 9 +- app/_components/session-page.tsx | 4 +- app/_components/sessions-page.tsx | 17 +- app/_components/subject-page.tsx | 6 +- app/_components/timeline-event-card.tsx | 226 +++++------------- .../timeline-event-inputs-table.tsx | 57 +++++ app/_components/timeline-events.tsx | 36 +-- app/_components/timeline-session-card.tsx | 135 +++++++++++ app/_mutations/delete-event.ts | 11 + app/_mutations/upsert-event-type.ts | 2 +- app/_mutations/upsert-session.ts | 1 + app/_queries/get-session.ts | 1 + app/_queries/list-events.ts | 3 +- bun.lockb | Bin 359940 -> 359940 bytes package.json | 8 +- ...9_add-sessions-title-length-constraint.sql | 3 + .../20240710183740_add-events-delete-rls.sql | 9 + ...dd-session-title-to-public-list-events.sql | 108 +++++++++ 27 files changed, 546 insertions(+), 264 deletions(-) create mode 100644 app/_components/event-menu.tsx create mode 100644 app/_components/timeline-event-inputs-table.tsx create mode 100644 app/_components/timeline-session-card.tsx create mode 100644 app/_mutations/delete-event.ts create mode 100644 supabase/migrations/20240709193309_add-sessions-title-length-constraint.sql create mode 100644 supabase/migrations/20240710183740_add-events-delete-rls.sql create mode 100644 supabase/migrations/20240710183932_add-session-title-to-public-list-events.sql diff --git a/app/(pages)/(with-nav)/layout.tsx b/app/(pages)/(with-nav)/layout.tsx index a81adfd9..5f87b231 100644 --- a/app/(pages)/(with-nav)/layout.tsx +++ b/app/(pages)/(with-nav)/layout.tsx @@ -17,6 +17,8 @@ const Layout = async ({ children }: LayoutProps) => { getCurrentUser(), ]); + if (!user) return null; + return (
@@ -26,7 +28,7 @@ const Layout = async ({ children }: LayoutProps) => { - {!user?.user_metadata?.is_client && ( + {!user.user_metadata.is_client && ( <> -
-
{children}
- -); +const Layout = async ({ children }: LayoutProps) => { + const user = await getCurrentUser(); + if (!user) return null; + + return ( + <> + {!user.user_metadata.is_client && ( +
+

Subjects

+ +
+ )} +
{children}
+ + ); +}; export default Layout; diff --git a/app/_components/event-comment.tsx b/app/_components/event-comment.tsx index 88d41fe3..ebd0bcae 100644 --- a/app/_components/event-comment.tsx +++ b/app/_components/event-comment.tsx @@ -14,6 +14,7 @@ import { useToggle } from '@uidotdev/usehooks'; interface EventCommentProps { content: string; createdAt: string; + hideCommentTimestamp?: boolean; id: string; profile: Database['public']['Tables']['profiles']['Row']; isArchived?: boolean; @@ -25,6 +26,7 @@ interface EventCommentProps { const EventComment = ({ content, createdAt, + hideCommentTimestamp, id, profile, isArchived, @@ -50,11 +52,13 @@ const EventComment = ({ {profile.first_name} {profile.last_name} - + {!hideCommentTimestamp && ( + + )} {!isPublic && !isArchived && diff --git a/app/_components/event-comments.tsx b/app/_components/event-comments.tsx index c52281b7..b6af0402 100644 --- a/app/_components/event-comments.tsx +++ b/app/_components/event-comments.tsx @@ -12,6 +12,7 @@ interface EventCommentsProps { id: string; profile: Database['public']['Tables']['profiles']['Row']; }>; + hideCommentTimestamp?: boolean; isArchived?: boolean; isPublic?: boolean; isTeamMember?: boolean; @@ -21,6 +22,7 @@ interface EventCommentsProps { const EventComments = ({ className, comments, + hideCommentTimestamp, isArchived, isPublic, isTeamMember, @@ -34,6 +36,7 @@ const EventComments = ({ { + const [deleteAlert, toggleDeleteAlert] = useToggle(false); + + return ( + <> + +
+ +
+ + } + > + + toggleDeleteAlert(true)}> + + Delete + + +
+ deleteEvent(eventId)} + /> + + ); +}; + +export default EventMenu; diff --git a/app/_components/event-page.tsx b/app/_components/event-page.tsx index 8edda74f..e9c694af 100644 --- a/app/_components/event-page.tsx +++ b/app/_components/event-page.tsx @@ -32,7 +32,7 @@ const EventPage = async ({ eventId, isPublic, subjectId }: EventPageProps) => { subtitle={ <> {event && ( -
+
{event.type.session ? 'Completed' : 'Recorded'} by {
)} {event.type.session && ( -
- - Session {Number(event.type.session.order) + 1} - - - Module {Number(event.type.order) + 1} - - -
+ )} } - title={event.type.name ?? event.type.session?.mission?.name} + title={ + event.type.session + ? `Module ${Number(event.type.order) + 1}${event.type.name ? `: ${event.type.name}` : ''}` + : event.type.name + } /> -
+
+
Module {(eventType.order as number) + 1} + {eventType.name ? `: ${eventType.name}` : ''}
{event && ( -
+
Completed by } - titleClassName="sm:pl-8 border-0 my-0 pr-6 sm:pr-10" + titleClassName="sm:pl-8 border-0 my-0 pr-6 sm:pr-10 hover:bg-alpha-1 active:bg-alpha-1" > {eventType.content && ( {eventType.content} diff --git a/app/_components/module-form-section.tsx b/app/_components/module-form-section.tsx index c1681481..960c01da 100644 --- a/app/_components/module-form-section.tsx +++ b/app/_components/module-form-section.tsx @@ -34,6 +34,7 @@ import { } from '@headlessui/react'; import DropdownMenu from '@/_components/dropdown-menu'; +import Input from '@/_components/input'; import { ArrayPath, Controller, @@ -100,8 +101,8 @@ const ModuleFormSection = >({ return (
  • >({
  • + )} + /> >({ instanceId="template-select" noOptionsMessage={() => 'No templates.'} onChange={(t) => { - const template = ( - t as NonNullable[0] - )?.data as TemplateDataJson; + const template = + t as NonNullable[0]; + + const data = template?.data as TemplateDataJson; const inputs = availableInputs.filter(({ id }) => - forceArray(template?.inputIds).includes(id), + forceArray(data?.inputIds).includes(id), ) as PathValue; + form.setValue( + `modules[${eventTypeIndex}].name` as Path, + template?.name as PathValue>, + { shouldDirty: true }, + ); + form.setValue( `modules[${eventTypeIndex}].content` as Path, - template?.content as PathValue>, + data?.content as PathValue>, { shouldDirty: true }, ); diff --git a/app/_components/select.tsx b/app/_components/select.tsx index 1b463ef2..1cce19d7 100644 --- a/app/_components/select.tsx +++ b/app/_components/select.tsx @@ -131,10 +131,7 @@ const Menu = ({ children, ...props }: MenuProps) => ( - +
    {children}
    diff --git a/app/_components/session-form.tsx b/app/_components/session-form.tsx index afb24e08..837a2f0c 100644 --- a/app/_components/session-form.tsx +++ b/app/_components/session-form.tsx @@ -64,9 +64,10 @@ type SessionFormValues = { content: string; id?: string; inputs: Array; + name?: string | null; }>; scheduledFor: string | null; - title: string; + title?: string | null; }; const SessionForm = ({ @@ -106,6 +107,7 @@ const SessionForm = ({ inputs: availableInputs.filter((input) => module.inputs.some(({ input_id }) => input_id === input.id), ), + name: module.name, })) : [{ content: '', inputs: [] }], scheduledFor: @@ -114,7 +116,7 @@ const SessionForm = ({ new Date(session.scheduled_for) < new Date()) ? null : formatDatetimeLocal(session.scheduled_for, { seconds: false }), - title: session?.title ?? '', + title: session?.title, }, }, { ignoreValues: ['draft', 'order'] }, @@ -179,7 +181,8 @@ const SessionForm = ({ >
    ))} @@ -179,11 +179,9 @@ const SubjectPage = async ({ ) : ( <> diff --git a/app/_components/timeline-event-card.tsx b/app/_components/timeline-event-card.tsx index b49a3d0d..765a5f87 100644 --- a/app/_components/timeline-event-card.tsx +++ b/app/_components/timeline-event-card.tsx @@ -3,206 +3,90 @@ import Avatar from '@/_components/avatar'; import Button from '@/_components/button'; import DateTime from '@/_components/date-time'; -import EventCommentForm from '@/_components/event-comment-form'; -import InputType from '@/_constants/enum-input-type'; +import EventMenu from '@/_components/event-menu'; +import TimelineEventInputsTable from '@/_components/timeline-event-inputs-table'; import { ListEventsData } from '@/_queries/list-events'; -import firstIfArray from '@/_utilities/first-if-array'; -import forceArray from '@/_utilities/force-array'; -import formatInputValue from '@/_utilities/format-input-value'; import ArrowUpRightIcon from '@heroicons/react/24/outline/ArrowUpRightIcon'; -import { User } from '@supabase/supabase-js'; -import { useRef } from 'react'; -import { twMerge } from 'tailwind-merge'; import EventComments, { EventCommentsProps, } from '@/_components/event-comments'; +import { twMerge } from 'tailwind-merge'; interface TimelineEventCardProps { - group: NonNullable; + event: NonNullable[0]; isArchived?: boolean; isPublic?: boolean; isTeamMember: boolean; subjectId: string; - user: User | null; } const TimelineEventCard = ({ - group, + event, isArchived, isPublic, isTeamMember, subjectId, - user, }: TimelineEventCardProps) => { - const compressLast = useRef(false); - const compressStart = useRef(null); - const lastEvent = group[group.length - 1]; - const lastEventProfile = firstIfArray(lastEvent.profile); - const lastEventType = firstIfArray(lastEvent.type); - const sessionNumber = (lastEventType?.session?.order ?? 0) + 1; const shareOrSubjects = isPublic ? 'share' : 'subjects'; return ( -
    +
    - {!lastEventType?.session && ( -
    - - - {lastEventProfile?.first_name} {lastEventProfile?.last_name} - - +
    +
    +
    +
    {event.type?.name}
    +
    +
    + + +
    +
    +
    +
    +
    Recorded by
    + +
    +
    + {event.profile?.first_name} {event.profile?.last_name} +
    +
    +
    +
    - )} -
      )} - > - {group.map((event, i) => { - const moduleNumber = (firstIfArray(event.type)?.order ?? 0) + 1; - const nextEvent = group[i + 1]; - - if (compressLast.current) { - compressLast.current = false; - compressStart.current = null; - } - - if ( - !event.comments.length && - !event.inputs.length && - nextEvent?.profile?.id === event.profile?.id && - !forceArray(nextEvent?.comments).length && - !forceArray(nextEvent?.inputs).length - ) { - if (!compressStart.current) compressStart.current = moduleNumber; - return null; - } else { - compressLast.current = !!compressStart.current; - } - - return ( -
    • - {lastEventType?.session && ( -
      - - {compressLast.current && ( - <>{compressStart.current} –  - )} - {moduleNumber} - - - - {event.profile?.first_name} {event.profile?.last_name}{' '} - - -
      - )} - {!!event.inputs.length && ( -
      - - - {Object.entries( - event.inputs.reduce< - Record< - string, - { - label: string; - type: InputType; - values: { - label?: string; - value?: string; - }[]; - } - > - >((acc, { input, option, value }) => { - if (!input) return acc; - acc[input.id] = acc[input.id] ?? { values: [] }; - acc[input.id].label = input.label; - acc[input.id].type = input.type as InputType; - - if (value || option?.label) { - acc[input.id].values.push({ - label: option?.label, - value: value as string, - }); - } - - return acc; - }, {}), - ).map(([id, { label, type, values }]) => ( - - - - - ))} - -
      {label} - {formatInputValue[type as InputType](values)} -
      -
      - )} - {!!event.comments.length && ( -
      - - {!isPublic && !isArchived && ( - - )} -
      - )} -
    • - ); - })} -
    -
    + {!!event.comments.length && ( +
    + +
    + )} + + {isTeamMember && } +
    ); }; diff --git a/app/_components/timeline-event-inputs-table.tsx b/app/_components/timeline-event-inputs-table.tsx new file mode 100644 index 00000000..cc1be273 --- /dev/null +++ b/app/_components/timeline-event-inputs-table.tsx @@ -0,0 +1,57 @@ +import InputType from '@/_constants/enum-input-type'; +import { ListEventsData } from '@/_queries/list-events'; +import formatInputValue from '@/_utilities/format-input-value'; +import { twMerge } from 'tailwind-merge'; + +interface TimelineEventInputsTableProps { + className?: string; + inputs: NonNullable[0]['inputs']; +} + +const TimelineEventInputsTable = ({ + className, + inputs, +}: TimelineEventInputsTableProps) => ( + + + {Object.entries( + inputs.reduce< + Record< + string, + { + label: string; + type: InputType; + values: { + label?: string; + value?: string; + }[]; + } + > + >((acc, { input, option, value }) => { + if (!input) return acc; + acc[input.id] = acc[input.id] ?? { values: [] }; + acc[input.id].label = input.label; + acc[input.id].type = input.type as InputType; + + if (value || option?.label) { + acc[input.id].values.push({ + label: option?.label, + value: value as string, + }); + } + + return acc; + }, {}), + ).map(([id, { label, type, values }]) => ( + + + + + ))} + +
    {label} + {formatInputValue[type as InputType](values)} +
    +); + +export default TimelineEventInputsTable; diff --git a/app/_components/timeline-events.tsx b/app/_components/timeline-events.tsx index f301a927..47557e8b 100644 --- a/app/_components/timeline-events.tsx +++ b/app/_components/timeline-events.tsx @@ -2,11 +2,11 @@ import Button from '@/_components/button'; import DateTime from '@/_components/date-time'; +import TimelineSessionCard from '@/_components/timeline-session-card'; import listEvents, { ListEventsData } from '@/_queries/list-events'; import listPublicEvents from '@/_queries/list-public-events'; import EventFilters from '@/_types/event-filters'; import formatTimelineEvents from '@/_utilities/format-timeline-events'; -import { User } from '@supabase/supabase-js'; import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState, useTransition } from 'react'; import TimelineEventCard from './timeline-event-card'; @@ -14,21 +14,17 @@ import TimelineEventCard from './timeline-event-card'; interface TimelineEventsProps { events: NonNullable; filters: EventFilters; - isArchived?: boolean; isPublic?: boolean; isTeamMember: boolean; subjectId: string; - user: User | null; } const TimelineEvents = ({ events, filters, - isArchived, isPublic, isTeamMember, subjectId, - user, }: TimelineEventsProps) => { const [eventsState, setEventsState] = useState(events); const [isTransitioning, startTransition] = useTransition(); @@ -62,17 +58,25 @@ const TimelineEvents = ({ date={firstEvent.created_at} formatter="date" /> - {dayGroup.map((eventGroup) => ( - - ))} + {dayGroup.map((eventGroup) => + eventGroup[0]?.type?.session?.id ? ( + + ) : ( + + ), + )}
    ); })} diff --git a/app/_components/timeline-session-card.tsx b/app/_components/timeline-session-card.tsx new file mode 100644 index 00000000..14566b4f --- /dev/null +++ b/app/_components/timeline-session-card.tsx @@ -0,0 +1,135 @@ +'use client'; + +import Avatar from '@/_components/avatar'; +import Button from '@/_components/button'; +import DateTime from '@/_components/date-time'; +import { ListEventsData } from '@/_queries/list-events'; +import firstIfArray from '@/_utilities/first-if-array'; +import ArrowUpRightIcon from '@heroicons/react/24/outline/ArrowUpRightIcon'; + +import EventComments, { + EventCommentsProps, +} from '@/_components/event-comments'; +import EventMenu from '@/_components/event-menu'; +import TimelineEventInputsTable from '@/_components/timeline-event-inputs-table'; +import { twMerge } from 'tailwind-merge'; + +interface TimelineSessionCardProps { + group: NonNullable; + isArchived?: boolean; + isPublic?: boolean; + isTeamMember: boolean; + subjectId: string; +} + +const TimelineSessionCard = ({ + group, + isArchived, + isPublic, + isTeamMember, + subjectId, +}: TimelineSessionCardProps) => { + const lastEvent = group[group.length - 1]; + const lastEventType = firstIfArray(lastEvent.type); + const sessionNumber = (lastEventType?.session?.order ?? 0) + 1; + const shareOrSubjects = isPublic ? 'share' : 'subjects'; + + return ( +
    + +
      + {group.map((event) => ( +
    • + + {isTeamMember && } +
    • + ))} +
    +
    + ); +}; + +export default TimelineSessionCard; diff --git a/app/_mutations/delete-event.ts b/app/_mutations/delete-event.ts new file mode 100644 index 00000000..d2854e94 --- /dev/null +++ b/app/_mutations/delete-event.ts @@ -0,0 +1,11 @@ +'use server'; + +import createServerSupabaseClient from '@/_utilities/create-server-supabase-client'; +import { revalidatePath } from 'next/cache'; + +const deleteEvent = async (id: string) => { + await createServerSupabaseClient().from('events').delete().eq('id', id); + revalidatePath('/', 'layout'); +}; + +export default deleteEvent; diff --git a/app/_mutations/upsert-event-type.ts b/app/_mutations/upsert-event-type.ts index e290bf43..e080199b 100644 --- a/app/_mutations/upsert-event-type.ts +++ b/app/_mutations/upsert-event-type.ts @@ -15,7 +15,7 @@ const upsertEventType = async ( .upsert({ content: sanitizeHtml(data.content) || null, id: context.eventTypeId, - name: data.name?.trim(), + name: data.name?.trim() || null, subject_id: context.subjectId, }) .select('id') diff --git a/app/_mutations/upsert-session.ts b/app/_mutations/upsert-session.ts index 59c28442..e3608380 100644 --- a/app/_mutations/upsert-session.ts +++ b/app/_mutations/upsert-session.ts @@ -77,6 +77,7 @@ const upsertSession = async ( (acc, module, order) => { const payload: Database['public']['Tables']['event_types']['Insert'] = { content: sanitizeHtml(module.content), + name: module.name?.trim() || null, order, session_id: session.id, subject_id: context.subjectId, diff --git a/app/_queries/get-session.ts b/app/_queries/get-session.ts index a31a5233..9918743a 100644 --- a/app/_queries/get-session.ts +++ b/app/_queries/get-session.ts @@ -13,6 +13,7 @@ const getSession = (sessionId: string) => event:events(id), id, inputs:event_type_inputs(input_id), + name, order ), scheduled_for, diff --git a/app/_queries/list-events.ts b/app/_queries/list-events.ts index e12bfe1b..cd55f57c 100644 --- a/app/_queries/list-events.ts +++ b/app/_queries/list-events.ts @@ -30,7 +30,8 @@ const listEvents = ( session:sessions( id, mission:missions(id, name), - order + order, + title ), name, order diff --git a/bun.lockb b/bun.lockb index f053b030d41aaf8cc988af3a9b6d053847b23330..5a30552dac25b5cfd0001b0a27652db8ede4777d 100755 GIT binary patch delta 2851 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 diff --git a/package.json b/package.json index 2ee7c6df..bc846fef 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.1.2", - "@heroicons/react": "^2.1.4", + "@heroicons/react": "^2.1.5", "@observablehq/plot": "^0.6.15", "@radix-ui/react-collapsible": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.1", @@ -56,7 +56,7 @@ "@visx/responsive": "^3.10.2", "autoprefixer": "^10.4.19", "eslint": "8.57.0", - "eslint-config-next": "^14.2.4", + "eslint-config-next": "^14.2.5", "eslint-config-prettier": "^9.1.0", "fuse.js": "^7.0.0", "humanize-duration": "^3.32.1", @@ -73,11 +73,11 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.52.1", "react-select": "^5.8.0", - "supabase": "^1.178.2", + "supabase": "^1.183.5", "tailwind-merge": "^2.4.0", "tailwindcss": "^3.4.4", "typescript": "^5.5.3", - "vercel": "^34.3.0", + "vercel": "^34.3.1", "xss": "^1.0.15" } } diff --git a/supabase/migrations/20240709193309_add-sessions-title-length-constraint.sql b/supabase/migrations/20240709193309_add-sessions-title-length-constraint.sql new file mode 100644 index 00000000..3a641482 --- /dev/null +++ b/supabase/migrations/20240709193309_add-sessions-title-length-constraint.sql @@ -0,0 +1,3 @@ +update public.sessions set title = null where title = ''; +alter table "public"."sessions" add constraint "sessions_title_length" check (((length(title) > 0) and (length(title) < 50))) not valid; +alter table "public"."sessions" validate constraint "sessions_title_length"; diff --git a/supabase/migrations/20240710183740_add-events-delete-rls.sql b/supabase/migrations/20240710183740_add-events-delete-rls.sql new file mode 100644 index 00000000..5a9d6840 --- /dev/null +++ b/supabase/migrations/20240710183740_add-events-delete-rls.sql @@ -0,0 +1,9 @@ +create policy "Team members can delete." on "public"."events" as permissive + for delete to authenticated + using ((exists ( + select 1 + from team_members tm + where ((tm.team_id = ( + select s.team_id + from subjects s + where (s.id = events.subject_id))) and (tm.profile_id = auth.uid ()))))); diff --git a/supabase/migrations/20240710183932_add-session-title-to-public-list-events.sql b/supabase/migrations/20240710183932_add-session-title-to-public-list-events.sql new file mode 100644 index 00000000..705c0a34 --- /dev/null +++ b/supabase/migrations/20240710183932_add-session-title-to-public-list-events.sql @@ -0,0 +1,108 @@ +create or replace function public.list_public_events( + public_subject_id uuid, + from_arg int default 0, + to_arg int default 10000, + start_date timestamp without time zone default NULL, + end_date timestamp without time zone default NULL + ) + returns json + language plpgsql + security definer + as $$ + declare + result json; + limit_count int; + begin + limit_count := to_arg - from_arg + 1; + select json_agg(event_info) + into result + from ( + select + json_build_object( + 'id', e.id, + 'created_at', e.created_at, + 'comments', coalesce(( + select json_agg(json_build_object( + 'content', c.content, + 'created_at', c.created_at, + 'id', c.id, + 'profile', ( + select json_build_object( + 'first_name', case when sm.profile_id is not null then (select anonymize_name(p.first_name, 'first')) else p.first_name end, + 'id', p.id, + 'image_uri', case when sm.profile_id is not null then null else p.image_uri end, + 'last_name', case when sm.profile_id is not null then (select anonymize_name(p.last_name, 'last')) else p.last_name end + ) + from profiles p + left join subject_managers sm on p.id = sm.profile_id and e.subject_id = sm.subject_id + where p.id = c.profile_id + ) + )) + from comments c + left join profiles p on c.profile_id = p.id + where c.event_id = e.id + ), '[]'::json), + 'inputs', coalesce(( + select json_agg(json_build_object( + 'input', json_build_object( + 'id', i.id, + 'label', i.label, + 'type', i.type + ), + 'option', json_build_object( + 'id', io.id, + 'label', io.label + ), + 'value', ei.value + )) + from event_inputs ei + left join inputs i on ei.input_id = i.id + left join input_options io on ei.input_option_id = io.id + where ei.event_id = e.id + ), '[]'::json), + 'profile', ( + select json_build_object( + 'first_name', case when sm.profile_id is not null then + (select anonymize_name(p.first_name, 'first')) else p.first_name end, + 'id', p.id, + 'image_uri', case when sm.profile_id is not null then null else p.image_uri end, + 'last_name', case when sm.profile_id is not null then (select anonymize_name(p.last_name, 'last')) else p.last_name end + ) + from profiles p + left join subject_managers sm on p.id = sm.profile_id and e.subject_id = sm.subject_id + where p.id = e.profile_id + ), + 'type', (select json_build_object( + 'id', et.id, + 'name', et.name, + 'order', et.order, + 'session', (select json_build_object( + 'id', s.id, + 'order', s.order, + 'mission', (select json_build_object( + 'id', m.id, + 'name', m.name + ), + 'title', s.title + from missions m + where m.id = s.mission_id) + ) + from sessions s + where s.id = et.session_id) + ) + from event_types et + where et.id = e.event_type_id + ) + ) as event_info, e.created_at + from events e + join subjects s on e.subject_id = s.id + where e.subject_id = public_subject_id and s.public = true + and (start_date IS NULL OR e.created_at >= start_date) + and (end_date IS NULL OR e.created_at < end_date) + order by e.created_at desc + offset from_arg + limit limit_count + ) as sub; + return result; + end; + $$;