diff --git a/client/src/locales/de-DE.ftl b/client/src/locales/de-DE.ftl index 168e984..df8a2f6 100644 --- a/client/src/locales/de-DE.ftl +++ b/client/src/locales/de-DE.ftl @@ -52,11 +52,13 @@ times-start-time-label = Startzeit times-end-time-label = Endzeit times-delete = Löschen times-calendar-week = KW { $week } +times-loading-more = Lade mehr… # Projects projects-title = Projekte projects-create = Projekt erstellen projects-loading = Projekte werden geladen … +projects-loading-more = Lade mehr… projects-edit = Bearbeiten projects-delete = Löschen @@ -64,6 +66,7 @@ projects-delete = Löschen organizations-title = Organisationen organizations-create = Organisation erstellen organizations-loading = Organisationen werden geladen … +organizations-loading-more = Lade mehr… organizations-open = Öffnen organizations-edit = Bearbeiten organizations-delete = Löschen diff --git a/client/src/locales/en-US.ftl b/client/src/locales/en-US.ftl index 3645b28..158ff4b 100644 --- a/client/src/locales/en-US.ftl +++ b/client/src/locales/en-US.ftl @@ -52,11 +52,13 @@ times-start-time-label = Start time times-end-time-label = End time times-delete = Delete times-calendar-week = CW { $week } +times-loading-more = Loading more... # Projects projects-title = Projects projects-create = Create project projects-loading = Loading projects... +projects-loading-more = Loading more... projects-edit = Edit projects-delete = Delete @@ -64,6 +66,7 @@ projects-delete = Delete organizations-title = Organizations organizations-create = Create organization organizations-loading = Loading organizations... +organizations-loading-more = Loading more... organizations-open = Open organizations-edit = Edit organizations-delete = Delete diff --git a/client/src/organization.ts b/client/src/organization.ts index 4e7631c..ce0e43e 100644 --- a/client/src/organization.ts +++ b/client/src/organization.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/solid-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/solid-query"; import { QUERY_BASE, type UserId } from "@/user"; @@ -77,6 +77,30 @@ export const isOrganization = ( organization: Organization | OptimisticOrganization, ): organization is Organization => "id" in organization; +const ORG_PAGE_SIZE = 20; + +export const pagedQuery = (userId: UserId | undefined) => + infiniteQueryOptions({ + queryKey: [QUERY_BASE, userId, "organizations", "paged"], + async queryFn({ pageParam, signal }): Promise { + const response = await fetch( + `/api/users/${userId}/organizations?page=${pageParam}&size=${ORG_PAGE_SIZE}`, + { method: "GET", signal }, + ); + + if (!response.ok) + throw new Error( + `Error fetching organizations: ${response.status} ${await response.text()}`, + ); + + return (await response.json()) as Organization[]; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => + lastPage.length === ORG_PAGE_SIZE ? allPages.length : undefined, + enabled: Boolean(userId), + }); + export async function addMember(organizationId: Id, username: string) { const response = await fetch(`/api/organizations/${organizationId}/members/registrations`, { method: "POST", diff --git a/client/src/project.ts b/client/src/project.ts index 40c5fcf..434178d 100644 --- a/client/src/project.ts +++ b/client/src/project.ts @@ -1,4 +1,4 @@ -import { queryOptions, useQueryClient } from "@tanstack/solid-query"; +import { infiniteQueryOptions, queryOptions, useQueryClient } from "@tanstack/solid-query"; import { createResource } from "solid-js"; import { type Organization, type Id as OrganizationId } from "./organization"; @@ -68,6 +68,28 @@ export async function updateProject({ userId, id, ...parameters }: UpdateProject export const isProject = (project: Project | OptimisticProject): project is Project => "id" in project; +const PROJECT_PAGE_SIZE = 20; + +export const pagedQuery = (userId: string | undefined) => + infiniteQueryOptions({ + queryKey: [QUERY_BASE, userId, "projects", "paged"], + async queryFn({ pageParam, signal }): Promise { + const response = await fetch( + `/api/users/${userId}/projects?page=${pageParam}&size=${PROJECT_PAGE_SIZE}`, + { method: "GET", signal }, + ); + + if (!response.ok) + throw new Error(`Error fetching projects: ${response.status} ${await response.text()}`); + + return (await response.json()) as Project[]; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => + lastPage.length === PROJECT_PAGE_SIZE ? allPages.length : undefined, + enabled: Boolean(userId), + }); + export async function deleteProject(id: Id) { const response = await fetch(`/api/projects/${id}`, { method: "DELETE", diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx index ee030bc..163758d 100644 --- a/client/src/routes/_app/organizations/index.tsx +++ b/client/src/routes/_app/organizations/index.tsx @@ -1,6 +1,6 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute, Link } from "@tanstack/solid-router"; -import { For, Show } from "solid-js"; +import { For, onCleanup, onMount, Show } from "solid-js"; import { FloatingActionButton } from "../../../FloatingActionButton"; import Icon from "../../../Icon"; @@ -8,7 +8,7 @@ import { useI18n } from "../../../i18n"; import { deleteOrganization, isOrganization, - query, + pagedQuery, type Id as OrganizationId, } from "../../../organization"; import { Title } from "../../../Title"; @@ -16,11 +16,10 @@ import { idQuery } from "../../../user"; export const Route = createFileRoute("/_app/organizations/")({ component: Organizations, - async loader({ context: { queryClient }, abortController }) { + async loader({ context: { queryClient } }) { const userId = await queryClient.ensureQueryData(idQuery); - const organizations = await queryClient.ensureQueryData(query(userId, abortController.signal)); - - return { organizations, userId }; + await queryClient.prefetchInfiniteQuery({ ...pagedQuery(userId), pages: 1 }); + return { userId }; }, }); @@ -29,13 +28,13 @@ function Organizations() { const userId = () => routeData().userId; const { t } = useI18n(); - const options = () => query(userId()); - const organizationsQuery = useQuery(options); + const organizationsQuery = useInfiniteQuery(() => pagedQuery(userId())); + const allOrganizations = () => organizationsQuery.data?.pages.flat() ?? []; const queryClient = useQueryClient(); const deleteMutation = useMutation(() => { const currentUserId = userId(); - const queryKey = ["user", currentUserId, "organizations"]; + const queryKey = pagedQuery(currentUserId).queryKey; return { mutationFn: deleteOrganization, @@ -48,8 +47,25 @@ function Organizations() { const isDeleting = (id: OrganizationId) => deleteMutation.isPending && deleteMutation.variables === id; - const isLastOrganization = () => - organizationsQuery.status === "success" && organizationsQuery.data?.length === 1; + const isLastOrganization = () => allOrganizations().length === 1; + + let sentinel!: HTMLDivElement; + onMount(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if ( + entry.isIntersecting && + organizationsQuery.hasNextPage && + !organizationsQuery.isFetchingNextPage + ) { + void organizationsQuery.fetchNextPage(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(sentinel); + onCleanup(() => observer.disconnect()); + }); return ( <> @@ -61,7 +77,7 @@ function Organizations() { />
    - {t("organizations-loading")}

    }> + {t("organizations-loading")}

    }> {(organization) => { return ( @@ -109,6 +125,10 @@ function Organizations() { }}
+
+ +

{t("organizations-loading-more")}

+
); diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx index fcb01e6..136c6c4 100644 --- a/client/src/routes/_app/projects.tsx +++ b/client/src/routes/_app/projects.tsx @@ -1,21 +1,20 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute, Link } from "@tanstack/solid-router"; -import { For, Show } from "solid-js"; +import { For, onCleanup, onMount, Show } from "solid-js"; import { FloatingActionButton } from "../../FloatingActionButton"; import Icon from "../../Icon"; import { useI18n } from "../../i18n"; -import { deleteProject, isProject, query, type Id as ProjectId } from "../../project"; +import { deleteProject, isProject, pagedQuery, type Id as ProjectId } from "../../project"; import { Title } from "../../Title"; import { idQuery } from "../../user"; export const Route = createFileRoute("/_app/projects")({ component: Projects, - async loader({ context: { queryClient }, abortController }) { + async loader({ context: { queryClient } }) { const userId = await queryClient.ensureQueryData(idQuery); - const projects = await queryClient.ensureQueryData(query(userId, abortController.signal)); - - return { projects, userId }; + await queryClient.prefetchInfiniteQuery({ ...pagedQuery(userId), pages: 1 }); + return { userId }; }, }); @@ -24,13 +23,14 @@ function Projects() { const userId = () => routeData().userId; const { t } = useI18n(); - const options = () => query(userId()); - const projectsQuery = useQuery(options); + const projectsQuery = useInfiniteQuery(() => pagedQuery(userId())); + const allProjects = () => projectsQuery.data?.pages.flat() ?? []; + const queryClient = useQueryClient(); const deleteMutation = useMutation(() => { const currentUserId = userId(); - const queryKey = ["user", currentUserId, "projects"]; + const queryKey = pagedQuery(currentUserId).queryKey; return { mutationFn: deleteProject, @@ -42,8 +42,25 @@ function Projects() { const isDeleting = (id: ProjectId) => deleteMutation.isPending && deleteMutation.variables === id; - const isLastProject = () => - projectsQuery.status === "success" && projectsQuery.data?.length === 1; + const isLastProject = () => allProjects().length === 1; + + let sentinel!: HTMLDivElement; + onMount(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if ( + entry.isIntersecting && + projectsQuery.hasNextPage && + !projectsQuery.isFetchingNextPage + ) { + void projectsQuery.fetchNextPage(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(sentinel); + onCleanup(() => observer.disconnect()); + }); return ( <> @@ -51,7 +68,7 @@ function Projects() {
    - {t("projects-loading")}

    }> + {t("projects-loading")}

    }> {(project) => { return ( @@ -89,8 +106,11 @@ function Projects() { }}
+
+ +

{t("projects-loading-more")}

+
); } - diff --git a/client/src/routes/_app/times.tsx b/client/src/routes/_app/times.tsx index f0b9104..24cb63e 100644 --- a/client/src/routes/_app/times.tsx +++ b/client/src/routes/_app/times.tsx @@ -1,6 +1,6 @@ -import { useMutation, useQuery } from "@tanstack/solid-query"; +import { useInfiniteQuery, useMutation } from "@tanstack/solid-query"; import { createFileRoute } from "@tanstack/solid-router"; -import { createMemo, createSignal, For, type VoidProps } from "solid-js"; +import { createMemo, createSignal, For, onCleanup, onMount, Show, type VoidProps } from "solid-js"; import { FloatingActionButton } from "@/FloatingActionButton"; import { useI18n } from "@/i18n"; @@ -16,27 +16,8 @@ export const Route = createFileRoute("/_app/times")({ component: Times, async loader({ context: { queryClient } }) { const userId = await queryClient.ensureQueryData(idQuery); - - const response = await fetch(`/api/users/${userId}/times`); - const times: Time[] = await response.json(); - for (const time of times) { - time.start = Temporal.Instant.from(time.start); - time.end = Temporal.Instant.from(time.end); - } - - const timesByDate = Map.groupBy(times, (time) => { - return time.start.toZonedDateTimeISO(timeZone).toPlainDate().toString(); - }); - - return { - userId, - times: timesByDate - .entries() - .map(([day, times]) => { - return [Temporal.PlainDate.from(day), times] as const; - }) - .toArray(), - }; + await queryClient.prefetchInfiniteQuery({ ...query(userId), pages: 1 }); + return { userId }; }, }); @@ -367,11 +348,14 @@ function Times() { const routeData = Route.useLoaderData(); const { t } = useI18n(); - const timesQuery = useQuery(() => query(routeData().userId)); + const timesQuery = useInfiniteQuery(() => query(routeData().userId)); + const weeks = () => { if (timesQuery.status !== "success") return; - const timesByDate = Map.groupBy(timesQuery.data, (time) => + const allTimes = timesQuery.data.pages.flat(); + + const timesByDate = Map.groupBy(allTimes, (time) => time.start.toZonedDateTimeISO(timeZone).toPlainDate().toString(), ); @@ -392,6 +376,20 @@ function Times() { .sort((left, right) => right.key.localeCompare(left.key)); }; + let sentinel!: HTMLDivElement; + onMount(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && timesQuery.hasNextPage && !timesQuery.isFetchingNextPage) { + void timesQuery.fetchNextPage(); + } + }, + { rootMargin: "200px" }, + ); + observer.observe(sentinel); + onCleanup(() => observer.disconnect()); + }); + return ( <> @@ -402,6 +400,10 @@ function Times() { <Week weekOfYear={week.weekOfYear} days={week.days} userId={routeData().userId} /> )} </For> + <div ref={sentinel} /> + <Show when={timesQuery.isFetchingNextPage}> + <p class="text-on-surface-variant py-4 text-center">{t("times-loading-more")}</p> + </Show> </main> </> ); diff --git a/client/src/time.ts b/client/src/time.ts index 79a0c84..859db5b 100644 --- a/client/src/time.ts +++ b/client/src/time.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/solid-query"; +import { infiniteQueryOptions } from "@tanstack/solid-query"; import type { UserId } from "./user"; @@ -10,14 +10,16 @@ export type Time = { end: Temporal.Instant; }; -export const query = (userId: string | undefined, signal?: AbortSignal) => - queryOptions({ +const PAGE_SIZE = 30; + +export const query = (userId: string | undefined) => + infiniteQueryOptions({ queryKey: ["users", userId, "times"], - async queryFn(): Promise<Time[]> { - const response = await fetch(`/api/users/${userId}/times`, { - method: "GET", - signal, - }); + async queryFn({ pageParam, signal }): Promise<Time[]> { + const response = await fetch( + `/api/users/${userId}/times?page=${pageParam}&size=${PAGE_SIZE}`, + { method: "GET", signal }, + ); if (!response.ok) throw new Error(`Error fetching times: ${response.status} ${await response.text()}`); @@ -30,6 +32,9 @@ export const query = (userId: string | undefined, signal?: AbortSignal) => return times; }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => + lastPage.length === PAGE_SIZE ? allPages.length : undefined, enabled: Boolean(userId), }); diff --git a/server/src/main/java/com/yealch/yealch/YealchApplication.java b/server/src/main/java/com/yealch/yealch/YealchApplication.java index f547a93..9de0970 100644 --- a/server/src/main/java/com/yealch/yealch/YealchApplication.java +++ b/server/src/main/java/com/yealch/yealch/YealchApplication.java @@ -42,16 +42,39 @@ public class YealchApplication { user.setPassword(passwordEncoder.encode("password")); userRepository.save(user); - var organization = new Organization(); - organization.setName("Yealch Organization"); - organization.addMember(user, OrganizationRole.OWNER); + // Seed 25 organizations (page size is 20, so this triggers a second page) + String[] orgNames = { + "Alpha Corp", "Beta Labs", "Gamma Inc", "Delta Studio", "Epsilon Works", + "Zeta Design", "Eta Group", "Theta Solutions", "Iota Agency", "Kappa Digital", + "Lambda Tech", "Mu Creative", "Nu Ventures", "Xi Partners", "Omicron Media", + "Pi Consulting", "Rho Systems", "Sigma Network", "Tau Industries", "Upsilon Co", + "Phi Software", "Chi Services", "Psi Analytics", "Omega Collective", "Prime Studio", + }; + + // First org gets multiple projects to push the projects list past page size 20 + var firstOrg = new Organization(); + firstOrg.setName(orgNames[0]); + firstOrg.addMember(user, OrganizationRole.OWNER); + for (int p = 1; p <= 22; p++) { + var project = new Project(); + project.setName("Project " + p); + firstOrg.addProject(project); + } + organizationRepository.save(firstOrg); + Project seedProject = firstOrg.getProjects().iterator().next(); - var project = new Project(); - project.setName("Main Project"); - organization.addProject(project); - organizationRepository.save(organization); + // Remaining orgs each get 1 project + for (int i = 1; i < orgNames.length; i++) { + var org = new Organization(); + org.setName(orgNames[i]); + org.addMember(user, OrganizationRole.OWNER); + var project = new Project(); + project.setName(orgNames[i] + " Project"); + org.addProject(project); + organizationRepository.save(org); + } - // Seed 30 days of time entries (2–3 entries per day) + // Seed 60 days of time entries (2–3 entries per day → ~150 entries, 5 pages) OffsetDateTime base = OffsetDateTime.now(ZoneOffset.UTC).withHour(0).withMinute(0).withSecond(0).withNano(0); int[][] slots = { { 9, 0, 11, 30 }, @@ -59,7 +82,7 @@ public class YealchApplication { { 15, 0, 17, 45 }, }; - for (int day = 0; day < 30; day++) { + for (int day = 0; day < 60; day++) { OffsetDateTime date = base.minusDays(day); int entriesThisDay = (day % 3 == 0) ? 3 : 2; for (int s = 0; s < entriesThisDay; s++) { @@ -67,12 +90,13 @@ public class YealchApplication { var time = new Time(); time.setStart(date.withHour(slot[0]).withMinute(slot[1])); time.setEnd(date.withHour(slot[2]).withMinute(slot[3])); - time.setProject(project); + time.setProject(seedProject); timeRepository.save(time); } } - logger.info("Seeded 30 days of time entries for user '{}'", user.getUsername()); + logger.info("Seeded {} organizations, ~{} projects, ~150 time entries for user '{}'", + orgNames.length, orgNames.length + 21, user.getUsername()); }); } } diff --git a/server/src/main/java/com/yealch/yealch/organization/OrganizationRepository.java b/server/src/main/java/com/yealch/yealch/organization/OrganizationRepository.java index 919ce1c..e7f1bd1 100644 --- a/server/src/main/java/com/yealch/yealch/organization/OrganizationRepository.java +++ b/server/src/main/java/com/yealch/yealch/organization/OrganizationRepository.java @@ -1,8 +1,15 @@ package com.yealch.yealch.organization; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; import java.util.UUID; -public interface OrganizationRepository extends CrudRepository<Organization, UUID> { +public interface OrganizationRepository extends PagingAndSortingRepository<Organization, UUID>, CrudRepository<Organization, UUID> { + @Query("SELECT o FROM Organization o JOIN o.memberships m WHERE m.user.id = :userId") + Page<Organization> findByUserId(@Param("userId") UUID userId, Pageable pageable); } diff --git a/server/src/main/java/com/yealch/yealch/project/ProjectRepository.java b/server/src/main/java/com/yealch/yealch/project/ProjectRepository.java index c747565..6d97584 100644 --- a/server/src/main/java/com/yealch/yealch/project/ProjectRepository.java +++ b/server/src/main/java/com/yealch/yealch/project/ProjectRepository.java @@ -1,8 +1,15 @@ package com.yealch.yealch.project; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; import java.util.UUID; -public interface ProjectRepository extends CrudRepository<Project, UUID> { +public interface ProjectRepository extends PagingAndSortingRepository<Project, UUID>, CrudRepository<Project, UUID> { + @Query("SELECT p FROM Project p JOIN p.organization o JOIN o.memberships m WHERE m.user.id = :userId") + Page<Project> findByUserId(@Param("userId") UUID userId, Pageable pageable); } diff --git a/server/src/main/java/com/yealch/yealch/time/TimeRepository.java b/server/src/main/java/com/yealch/yealch/time/TimeRepository.java index 2f0865f..35c2f8d 100644 --- a/server/src/main/java/com/yealch/yealch/time/TimeRepository.java +++ b/server/src/main/java/com/yealch/yealch/time/TimeRepository.java @@ -1,8 +1,15 @@ package com.yealch.yealch.time; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.Param; import java.util.UUID; -public interface TimeRepository extends CrudRepository<Time, UUID> { +public interface TimeRepository extends PagingAndSortingRepository<Time, UUID>, CrudRepository<Time, UUID> { + @Query("SELECT t FROM Time t JOIN t.project p JOIN p.organization o JOIN o.memberships m WHERE m.user.id = :userId") + Page<Time> findByUserId(@Param("userId") UUID userId, Pageable pageable); } diff --git a/server/src/main/java/com/yealch/yealch/user/UsersController.java b/server/src/main/java/com/yealch/yealch/user/UsersController.java index fff04a1..ea72b35 100644 --- a/server/src/main/java/com/yealch/yealch/user/UsersController.java +++ b/server/src/main/java/com/yealch/yealch/user/UsersController.java @@ -8,6 +8,8 @@ import com.yealch.yealch.project.Project; import com.yealch.yealch.project.ProjectRepository; import com.yealch.yealch.time.Time; import com.yealch.yealch.time.TimeRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -18,13 +20,14 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.stream.StreamSupport; + @RestController public class UsersController { @@ -92,7 +95,10 @@ public class UsersController { } @GetMapping("/api/users/{userId}/organizations") - public ResponseEntity<?> getUserOrganizations(@PathVariable UUID userId, Authentication authentication) { + public ResponseEntity<?> getUserOrganizations(@PathVariable UUID userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Authentication authentication) { Optional<UUID> authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); @@ -117,7 +123,9 @@ public class UsersController { organizationRepository.save(defaultOrganization); } - return ResponseEntity.ok(user.getOrganizations().stream() + var pageable = PageRequest.of(page, size, Sort.by("name")); + return ResponseEntity.ok(organizationRepository.findByUserId(userId, pageable) + .getContent().stream() .map(organization -> new GetUserOrganizationsResponse(organization.getId(), organization.getName())) .toList()); }) @@ -132,7 +140,10 @@ public class UsersController { * project is created for them and assigned to the organization. */ @GetMapping("/api/users/{userId}/projects") - public ResponseEntity<?> getUserProjects(@PathVariable UUID userId, Authentication authentication) { + public ResponseEntity<?> getUserProjects(@PathVariable UUID userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Authentication authentication) { Optional<UUID> authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); @@ -156,12 +167,15 @@ public class UsersController { } }); - return ResponseEntity.ok(user.getOrganizations().stream() - .flatMap(organization -> organization.getProjects().stream() - .map(project -> new GetUserProjectsResponse( - project.getId(), - project.getName(), - new GetUserProjectsOrganizationResponse(organization.getId(), organization.getName())))) + var pageable = PageRequest.of(page, size, Sort.by("name")); + return ResponseEntity.ok(projectRepository.findByUserId(userId, pageable) + .getContent().stream() + .map(project -> new GetUserProjectsResponse( + project.getId(), + project.getName(), + new GetUserProjectsOrganizationResponse( + project.getOrganization().getId(), + project.getOrganization().getName()))) .toList()); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found"))); @@ -262,39 +276,33 @@ public class UsersController { } @GetMapping("/api/users/{userId}/times") - public ResponseEntity<?> getUserTimeEntries(@PathVariable UUID userId, Authentication authentication) { + public ResponseEntity<?> getUserTimeEntries(@PathVariable UUID userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "30") int size, + Authentication authentication) { Optional<UUID> authenticatedUserId = getUserId(authentication); if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - return userRepository.findById(userId) - .<ResponseEntity<?>>map(user -> ResponseEntity.ok( - StreamSupport.stream(timeRepository.findAll().spliterator(), false) - .filter(time -> { - Project project = time.getProject(); - if (project == null) { - return false; - } - Organization organization = project.getOrganization(); - if (organization == null) { - return false; - } - return organization.hasMember(user.getId()); - }) - .map(time -> new GetUserTimesResponse( - time.getId(), - time.getStart().toString(), - time.getEnd().toString(), - new GetUserTimesProjectResponse( - time.getProject().getId(), - time.getProject().getName(), - new GetUserTimesOrganizationResponse( - time.getProject().getOrganization().getId(), - time.getProject().getOrganization().getName())))) - .toList())) - .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponse("user not found"))); + if (!userRepository.existsById(userId)) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("user not found")); + } + + var pageable = PageRequest.of(page, size, Sort.by("start").descending()); + return ResponseEntity.ok(timeRepository.findByUserId(userId, pageable) + .getContent().stream() + .map(time -> new GetUserTimesResponse( + time.getId(), + time.getStart().toString(), + time.getEnd().toString(), + new GetUserTimesProjectResponse( + time.getProject().getId(), + time.getProject().getName(), + new GetUserTimesOrganizationResponse( + time.getProject().getOrganization().getId(), + time.getProject().getOrganization().getName())))) + .toList()); } /** Creates a new organization with this user in it */