Skip to content

sequence diagrams

Mehmet Bora Sarioglu edited this page May 16, 2026 · 1 revision

UML Sequence Diagrams — Final Milestone (MyMentorNet)

Source-controlled Mermaid diagrams that reflect the current production Spring Boot 3 backend under backend/src/main/java/com/group7/backend/. Each diagram is grounded in a real controller, service, repository, or @TransactionalEventListener-annotated component; no intermediate classes have been invented. Side-effect topology (AFTER_COMMIT, @Async, @Scheduled, STOMP, REQUIRES_NEW) is called out with Note over so the reader can distinguish synchronous RPC ordering from deferred, decoupled, or scheduled work.


1. Mentor discovery → request → accept → shared goal → first milestone

Happy-path onboarding of a mentee/mentor pair. The mentee browses ranked mentors via MatchingController.getTopMentors, opens a request through MentorshipRequestController.createRequest, the mentor accepts via MentorshipRequestController.acceptRequest (which delegates to MentorshipService.acceptRequest and cancels every other pending request from the same mentee), the pair sets the shared goal through MentorshipController -> MentorshipService.setSharedGoal, and the mentor creates the first milestone via MilestoneController -> MilestoneService.createMilestone. The MentorshipPreconditions.requireSharedGoal guard throws GoalRequiredException (HTTP 409 GOAL_REQUIRED) when a mentor tries to create a milestone before the shared goal is set.

sequenceDiagram
    actor Mentee
    participant MWC as MenteeWebClient
    participant MC as MatchingController
    participant MS as MatchingService
    participant Pipe as MentorScoringPipeline
    participant MRC as MentorshipRequestController
    participant MRS as MentorshipRequestService
    participant BS as BanService
    participant CP as MentorshipCooldownPolicy
    participant MReqRepo as MentorshipRequestRepository
    participant NEP as NotificationEventPublisher
    actor Mentor
    participant MtWC as MentorWebClient
    participant MShipS as MentorshipService
    participant MRepo as MentorshipRepository
    participant ALog as MentorshipAuditLogRepository
    participant MilC as MilestoneController
    participant MilS as MilestoneService
    participant MilRepo as MilestoneRepository

    %% 1. Discovery
    Mentee->>MWC: open "Find a Mentor"
    MWC->>+MC: GET /api/matching/mentors?keyword&page&size
    MC->>+MS: getTopMentors(menteeId, keyword, ..., pageable)
    Note over MS: TransactionTemplate (readOnly) opens
    MS->>MS: loadEligibleMentee(menteeId)
    MS->>MS: rankMentorsFor(mentee, keyword, ...)
    MS->>+Pipe: rank(rawCandidates, mentee, slots, ...)
    Pipe-->>-MS: List<MentorMatchResponse> (scored, sliced to page)
    Note over MS: read-only tx closes BEFORE LLM prose attach
    MS-->>-MC: Page<MentorMatchResponse>
    MC-->>-MWC: 200 OK Page<MentorMatchResponse>

    %% 2. Request creation
    Mentee->>MWC: pick mentor, click "Request"
    MWC->>+MRC: POST /api/mentorship-requests {mentorId, message}
    MRC->>+MRS: createRequest(menteeId, dto)
    MRS->>+BS: getActiveBan(menteeId)
    BS-->>-MRS: Optional.empty()
    alt mentee is banned
        BS-->>MRS: Optional<Ban>
        MRS-->>MRC: throw UserBannedException
        MRC-->>MWC: 403 BANNED_UNTIL {expiresAt, reason}
    end
    MRS->>CP: assertNotInCooldown(mentorId, menteeId)
    MRS->>MReqRepo: existsByMentee_IdAndMentor_IdAndStatus(PENDING)
    alt duplicate pending request
        MRS-->>MRC: throw MentorshipRequestException
        MRC-->>MWC: 409 "already have a pending request"
    end
    MRS->>MReqRepo: save(MentorshipRequest{status=PENDING})
    MReqRepo-->>MRS: saved
    MRS->>NEP: publishRequestReceived(mentorId, menteeFirstName)
    MRS->>NEP: publishRequestSubmitted(menteeId, mentorFirstName)
    MRS-->>-MRC: MentorshipRequestResponse
    MRC-->>-MWC: 201 Created

    %% 3. Mentor accepts
    Mentor->>MtWC: open "Received Requests", click Accept
    MtWC->>+MRC: PUT /api/mentorship-requests/{id}/accept {duration}
    MRC->>+MShipS: acceptRequest(mentorId, requestId, dto)
    MShipS->>MReqRepo: findById(requestId)
    MReqRepo-->>MShipS: MentorshipRequest (PENDING)
    MShipS->>CP: assertNotInCooldown(mentorId, menteeId)
    MShipS->>MShipS: validate duration in {1,3,6}, capacity, no active mentor
    MShipS->>MReqRepo: cancelOtherPendingRequests(menteeId, requestId)
    Note over MShipS,MReqRepo: side effect: every other PENDING request<br/>from this mentee flips to CANCELLED
    MShipS->>MRepo: save(Mentorship{status=ACTIVE, startDate=now, endDate=now+duration})
    MShipS->>ALog: save(MentorshipAuditLog ACTIVE)
    MShipS->>NEP: publishRequestAccepted(menteeId, mentorFirstName)
    MShipS-->>-MRC: MentorshipResponse
    MRC-->>-MtWC: 200 OK MentorshipResponse

    %% 4. Set shared goal
    Mentor->>MtWC: open mentorship, type goal, Save
    MtWC->>+MShipS: setSharedGoal(userId, mentorshipId, dto)  [via MentorshipController]
    MShipS->>MShipS: findForParticipant + checkActive
    MShipS->>MRepo: save(mentorship.sharedGoal = dto.sharedGoal)
    MShipS-->>-MtWC: 200 OK MentorshipResponse

    %% 5. First milestone (GOAL_REQUIRED gate)
    Mentor->>MtWC: open Roadmap, click "New milestone"
    MtWC->>+MilC: POST /api/mentorships/{id}/milestones {title, ...}
    MilC->>+MilS: createMilestone(mentorshipId, mentorId, dto)
    MilS->>MRepo: findById(mentorshipId)
    MilS->>MilS: checkMentorshipActive(mentorship)
    MilS->>MilS: MentorshipPreconditions.requireSharedGoal(mentorship)
    alt sharedGoal is null/blank
        MilS-->>MilC: throw GoalRequiredException
        MilC-->>MtWC: 409 {code: "GOAL_REQUIRED", mentorshipId}
    end
    MilS->>MilRepo: save(Milestone{status=PENDING})
    MilS->>NEP: publishMilestoneCreated(menteeId, title)
    MilS-->>-MilC: MilestoneDetailResponse
    MilC-->>-MtWC: 201 Created
Loading

2. Mentee cancels pending request → auto-ban → retry blocked → admin lifts

Cancellation flows are funneled through MentorshipRequestService.cancelOwnPendingRequest, which flips the request to CANCELLED and calls BanService.recordCancellation. Once cancelCount crosses BanProperties.cancellationThreshold, BanService writes a fresh Ban row whose duration follows the escalating-doubling policy capped at maxBanHours. A subsequent POST /api/mentorship-requests is short-circuited by the same BanService.getActiveBan gate inside MentorshipRequestService.createRequest with HTTP 403 BANNED_UNTIL. The admin lifts the ban via BanAdminController.liftBan -> BanService.liftBan, which fires publishBanLifted.

sequenceDiagram
    actor Mentee
    participant MC as MenteeClient
    participant MRC as MentorshipRequestController
    participant MRS as MentorshipRequestService
    participant MReqRepo as MentorshipRequestRepository
    participant BS as BanService
    participant BRepo as BanRepository
    participant MRepo2 as MenteeRepository
    participant NEP as NotificationEventPublisher
    actor Admin
    participant AC as AdminClient
    participant BAC as BanAdminController

    %% 1. Cancel pending request — threshold not yet crossed
    Mentee->>MC: click "Cancel" on pending request
    MC->>+MRC: DELETE /api/mentorship-requests/{id}
    MRC->>+MRS: cancelOwnPendingRequest(menteeId, requestId)
    MRS->>MReqRepo: findById(requestId)
    MReqRepo-->>MRS: MentorshipRequest (must be owner, PENDING)
    MRS->>MReqRepo: save(request.status = CANCELLED)
    MRS->>+BS: recordCancellation(menteeId, reason)
    BS->>MRepo2: findById(menteeId)
    BS->>MRepo2: save(mentee.cancelCount += 1)
    alt cancelCount < cancellationThreshold
        BS-->>MRS: Optional.empty()  (warning only)
    else cancelCount >= threshold
        BS->>BRepo: countNonLiftedByUserId(menteeId) → ordinal
        BS->>BRepo: save(Ban{expiresAt=now+computedHours, source=MENTEE_CANCELLATION})
        BS->>NEP: publishUserBanned(menteeId, expiresAt, reason, banCount)
        BS-->>-MRS: Optional<Ban>
    end
    MRS-->>-MRC: void
    MRC-->>-MC: 204 No Content

    %% 2. Retry: next POST short-circuits on ban gate
    Mentee->>MC: try to request a new mentor
    MC->>+MRC: POST /api/mentorship-requests {mentorId}
    MRC->>+MRS: createRequest(menteeId, dto)
    MRS->>+BS: getActiveBan(menteeId)
    BS->>BRepo: findActive(menteeId, now)
    BRepo-->>BS: Optional<Ban>
    BS-->>-MRS: Optional<Ban> (present)
    MRS-->>-MRC: throw UserBannedException(ban)
    MRC-->>-MC: 403 BANNED_UNTIL {expiresAt, reason}
    Note over MRS,BS: ban gate runs FIRST so the 403 carries<br/>expiresAt without touching mentor/capacity state

    %% 3. Admin lifts the ban
    Admin->>AC: open "Bans for user", click Lift
    AC->>+BAC: DELETE /api/admin/bans/{banId}
    BAC->>+BS: liftBan(banId, adminId)
    BS->>BRepo: findById(banId)
    alt ban already lifted
        BS-->>BAC: ban (no-op, no notification)
    else first lift
        BS->>BRepo: save(ban.liftedAt=now, ban.liftedByAdminId=adminId)
        BS->>NEP: publishBanLifted(userId)
    end
    BS-->>-BAC: Ban
    BAC-->>-AC: 200 OK BanResponse
Loading

3. Authenticated post creation with hashtag extraction + attachment fanout

POST /api/feed/posts is hit through FeedPostController.create. The RateLimitFilter consumes the feed-post-create bucket (USER key, capacity 30/hr by default) before reaching the controller. FeedPostService.create rejects Admin requesters, calls HashtagNormalizer.normalize to lowercase / strip leading # / dedupe, runs resolveAttachments (size cap 4, uploader-must-match-author provenance check, image-only content-type allow-list), persists the parent + children in one @Transactional boundary, and publishes a FeedPostCreatedEvent through FeedPostEventPublisher. The @TransactionalEventListener(AFTER_COMMIT) plus @Async on FeedFanoutListener.onFeedPostCreated snapshots followers via FollowRepository.findFollowerIdsByFolloweeId and broadcasts FeedPostPushPayload to each follower's STOMP topic.

sequenceDiagram
    actor User
    participant Cli as Client
    participant RLF as RateLimitFilter
    participant FPC as FeedPostController
    participant FPS as FeedPostService
    participant URepo as UserRepository
    participant HN as HashtagNormalizer
    participant ARepo as AttachmentRepository
    participant FRepo as FeedPostRepository
    participant FEP as FeedPostEventPublisher
    participant FFL as FeedFanoutListener
    participant FollowRepo as FollowRepository
    participant STOMP as SimpMessagingTemplate

    User->>Cli: write post, attach 0..4 images, Submit
    Cli->>+RLF: POST /api/feed/posts {body, hashtags[], attachmentIds[], lang}
    RLF->>RLF: consume "feed-post-create" bucket (USER key)
    alt bucket exhausted
        RLF-->>Cli: 429 Too Many Requests
    end
    RLF->>+FPC: forward
    FPC->>+FPS: create(authorId, body, rawHashtags, attachmentIds, lang)
    FPS->>URepo: findById(authorId)
    alt author instanceof Admin
        FPS-->>FPC: throw AccessDeniedException
        FPC-->>Cli: 403 "Admins cannot create feed posts"
    end
    FPS->>+HN: normalize(rawHashtags)
    HN-->>-FPS: Set<String> normalised
    FPS->>FPS: resolveAttachments(ids, authorId)
    loop for each attachmentId
        FPS->>ARepo: findById(id)
        FPS->>FPS: assert uploader == authorId AND imageContentType
    end
    FPS->>FRepo: save(FeedPost)
    FPS->>FRepo: persist hashtag + attachment children (cascade on commit)
    FPS->>FEP: publishCreated(saved, author)
    FEP->>FEP: ApplicationEventPublisher.publishEvent(FeedPostCreatedEvent)
    Note over FPS,FEP: still inside @Transactional —<br/>event is buffered until commit
    FPS-->>-FPC: FeedPostResponse
    FPC-->>-Cli: 201 Created
    Note over FPS,FFL: TX commits → AFTER_COMMIT listeners released
    par async fanout
        FFL->>+FFL: onFeedPostCreated(event)  [@Async]
        FFL->>FollowRepo: findFollowerIdsByFolloweeId(authorId)
        FollowRepo-->>FFL: Set<Long> followers
        loop for each followerId
            FFL->>STOMP: convertAndSend(/topic/feed.<id>, FeedPostPushPayload)
        end
        FFL-->>-FFL: log delivered count
    end
Loading

4. For-You feed read path including the AFTER_COMMIT bandit listener

FeedReadController.forYou calls FeedReadService.forYouFeed, which oversamples up to app.feed.forYou.candidate-window (default 200) recent posts via FeedPostRepository.findForYouCandidates, ranks them through the injected FeedRanker (@Primary implementation is InterestOverlapFeedRanker; when wired, the advanced path ForYouScoringPipeline runs MMR + diversity floor + bandit slots), applies the UserKeywordMuteService filter, batches counts via FeedInteractionService.batchCounts, and maps through FeedPostMapper.toListItems. Engagement (like / share / comment / repost) is recorded by FeedInteractionService, which publishes a FeedEngagementEvent inside the write transaction. FeedEngagementBanditListener.onFeedEngagement listens with @TransactionalEventListener(AFTER_COMMIT) + @Transactional(propagation=REQUIRES_NEW) — the dual-connection use is the reason the prod Hikari pool was bumped from 10 to 20.

sequenceDiagram
    actor Viewer
    participant Cli as Client
    participant FRC as FeedReadController
    participant FRS as FeedReadService
    participant FPRepo as FeedPostRepository
    participant FRanker as FeedRanker (InterestOverlapFeedRanker / ForYouScoringPipeline)
    participant KMS as UserKeywordMuteService
    participant FIS as FeedInteractionService
    participant FPMap as FeedPostMapper
    participant FELB as FeedEngagementBanditListener
    participant TS as ThompsonSamplingService
    participant BTab as BanditAlphaTable

    %% 4a. READ
    Viewer->>Cli: open For-You tab
    Cli->>+FRC: GET /api/feed/for-you?page&size
    FRC->>+FRS: forYouFeed(viewerId, pageable)
    FRS->>FPRepo: findForYouCandidates(viewerId, candidateWindow=200)
    FPRepo-->>FRS: List<FeedPost> (up to 200 recent)
    FRS->>FRS: buildRankingContext(viewerId) [interests + followee ids + now]
    loop for each candidate
        FRS->>FRanker: score(post, context)
        FRanker-->>FRS: FeedScoreResult{score, factors}
    end
    FRS->>FRS: Schwartzian sort + slice page
    FRS->>+KMS: filter(viewerId, sliced posts)
    KMS-->>-FRS: List<FeedPost> visible
    FRS->>+FIS: batchCounts(visiblePostIds)
    FIS-->>-FRS: Map<postId, PostCounts>
    FRS->>+FPMap: toListItems(visible, viewerId, counts, factors)
    FPMap-->>-FRS: List<FeedPostListItem>
    FRS-->>-FRC: Page<FeedPostListItem>
    FRC-->>-Cli: 200 OK Page

    %% 4b. ENGAGEMENT (like) → AFTER_COMMIT + REQUIRES_NEW bandit update
    Viewer->>Cli: tap "Like" on post
    Cli->>+FIS: POST /api/feed/posts/{id}/like
    Note over FIS: outer @Transactional opens (Hikari connection #1)
    FIS->>FPRepo: requireVisiblePost(postId)
    FIS->>FIS: upsertLike(postId, userId)
    FIS->>FIS: publishEngagement(post, viewerId)
    Note over FIS: ApplicationEventPublisher.publishEvent(FeedEngagementEvent{viewerId, postHashtags})
    FIS->>FIS: publishEngagementChanged(post)  → FeedPostEngagementChangedEvent
    FIS-->>-Cli: 200 FeedPostInteractionState
    Note over FIS,FELB: outer TX commits, connection #1 returned
    par AFTER_COMMIT bandit hop
        FELB->>+FELB: onFeedEngagement(event)
        Note over FELB: opens NEW transaction (Hikari connection #2)<br/>REQUIRES_NEW so bandit failure cannot poison the like
        FELB->>TS: recordEngagement(viewerId, postHashtags)
        TS->>BTab: UPSERT alpha += 1 for each hashtag
        TS-->>FELB: void
        FELB-->>-FELB: log (warns + swallows on failure)
    end
Loading

5. Feed search with keyword + hashtag + viewer-side keyword mute filtering

FeedReadController.search enforces "at least one of q, hashtag, since, until, lang is required" (else 400). The service lowercases + LIKE-escapes the keyword at the boundary so % and _ cannot act as wildcards, normalises the single hashtag through HashtagNormalizer.normalize, validates the date window, runs the pg_trgm-accelerated FeedPostRepository.searchPosts native query, then applies UserKeywordMuteService.filter after the SQL fetch — which is why the response can carry fewer than size items even when there are more matches on the next page.

sequenceDiagram
    actor Viewer
    participant Cli as Client
    participant FRC as FeedReadController
    participant FRS as FeedReadService
    participant HN as HashtagNormalizer
    participant FPRepo as FeedPostRepository
    participant KMS as UserKeywordMuteService
    participant KMRepo as UserKeywordMuteRepository
    participant FIS as FeedInteractionService
    participant FPMap as FeedPostMapper

    Viewer->>Cli: type query, optional #hashtag, Search
    Cli->>+FRC: GET /api/feed/search?q&hashtag&since&until&lang&page&size
    FRC->>+FRS: search(keyword, hashtag, since, until, lang, viewerId, pageable)
    alt all five filters null/blank
        FRS-->>FRC: throw IllegalArgumentException
        FRC-->>Cli: 400 "at least one of q, hashtag, since, until, lang"
    end
    FRS->>FRS: normalisedKeyword = escapeLikePattern(lowercased trimmed q)
    Note over FRS: escapes \\, %, _ so user input cannot become a wildcard
    FRS->>+HN: normalize([hashtag])
    HN-->>-FRS: normalisedHashtag (or null)
    alt hashtag supplied but failed normalisation
        FRS-->>FRC: Page.empty(pageable)
    end
    FRS->>FRS: validate since < until, window bounds (-10y .. +1d)
    FRS->>+FPRepo: searchPosts(normKeyword, normHashtag, since, until, lang, pageable)
    Note over FPRepo: hits idx_feed_posts_body_trgm (GIN) + feed_post_hashtags join
    FPRepo-->>-FRS: Page<FeedPost> (SQL-filtered, AND-ed)
    FRS->>+KMS: filter(viewerId, page.content)
    KMS->>KMRepo: countByUserId(viewerId)
    alt no mutes
        KMRepo-->>KMS: 0
        KMS-->>FRS: posts unchanged
    else has mutes
        KMS->>KMRepo: findByUserIdOrderByCreatedAtAsc(viewerId)
        loop for each post
            KMS->>KMS: drop if body lowercased contains any muted keyword
        end
        KMS-->>FRS: filtered (may be SHORTER than size)
    end
    deactivate KMS
    alt filtered empty
        FRS-->>FRC: PageImpl([], pageable, page.totalElements)
    end
    FRS->>+FIS: batchCounts(filteredIds)
    FIS-->>-FRS: counts map
    FRS->>+FPMap: toListItems(filtered, viewerId, counts)
    FPMap-->>-FRS: List<FeedPostListItem>
    FRS-->>-FRC: Page<FeedPostListItem> (may have < size items)
    FRC-->>-Cli: 200 OK Page
Loading

6. Mentor-pair message send with attachment + WebSocket fanout + read receipt

MentorPairMessageController.send resolves (or lazily creates) the mentor-pair Conversation via ConversationService.findOrCreateForMentorPair, then delegates to MessageService.send. The feed-post-create-style edge filter consumes the mentor-pair-message-send bucket (USER key, capacity 60/min). MessageService.send enforces participant + uploader-must-equal- sender provenance, persists, fires the MessageSentEvent, and the @TransactionalEventListener(AFTER_COMMIT) on MessageBroadcastListener.onMessageSent re-fetches with findByIdForBroadcast and STOMP-broadcasts to /topic/conversation/{id}. The recipient's subsequent PATCH /read lands in MessageService.markAllRead.

sequenceDiagram
    actor Sender
    participant SCli as SenderClient
    participant RLF as RateLimitFilter
    participant MPMC as MentorPairMessageController
    participant CS as ConversationService
    participant MS as MessageService
    participant ARepo as AttachmentRepository
    participant PartRepo as ConversationParticipantRepository
    participant MRepo as MessageRepository
    participant AEP as ApplicationEventPublisher
    participant NEP as NotificationEventPublisher
    participant MBL as MessageBroadcastListener
    participant STOMP as SimpMessagingTemplate
    participant Recipient as RecipientClient

    Sender->>SCli: type message, attach file (optional), Send
    SCli->>+RLF: POST /api/conversations/mentor-pair/{otherMentorId}/messages
    RLF->>RLF: consume "mentor-pair-message-send" bucket (USER key, 60/min)
    alt bucket exhausted
        RLF-->>SCli: 429 Too Many Requests
    end
    RLF->>+MPMC: forward
    MPMC->>+CS: findOrCreateForMentorPair(senderId, otherMentorId)
    CS-->>-MPMC: Conversation (existing or new)
    MPMC->>+MS: send(senderId, conversationId, request)
    Note over MS: @Transactional opens
    MS->>MRepo: loadAndAuthorize(conversationId, senderId)
    MS->>PartRepo: existsByConversationIdAndUserId
    alt attachmentId provided
        MS->>ARepo: findById(attachmentId)
        alt attachment.uploader != sender
            MS-->>MPMC: throw ProfileNotVisibleException
            MPMC-->>SCli: 403
        end
    end
    MS->>MRepo: save(Message{sentAt=now})
    MRepo-->>MS: saved
    MS->>PartRepo: findOtherParticipantUserIds(conversationId, senderId)
    MS->>AEP: publishEvent(MessageSentEvent{messageId, conversationId, senderId, recipientId})
    loop for each other participant
        MS->>NEP: publishNewMessage(recipientId, senderFirstName)
    end
    MS-->>-MPMC: MessageResponse
    MPMC-->>-SCli: 201 Created
    Note over MS,MBL: TX commits → AFTER_COMMIT listener fires
    MBL->>+MBL: onMessageSent(event)
    MBL->>MRepo: findByIdForBroadcast(messageId)  [eager fetch sender + conv + attach]
    MBL->>STOMP: convertAndSend("/topic/conversation/" + conversationId, MessageResponse)
    STOMP-->>Recipient: WebSocket frame
    deactivate MBL

    %% Read receipt
    Recipient->>+MPMC: PATCH /api/conversations/mentor-pair/{otherMentorId}/messages/read
    MPMC->>+CS: findOrCreateForMentorPair(recipientId, otherMentorId)
    CS-->>-MPMC: Conversation
    MPMC->>+MS: markAllRead(recipientId, conversationId)
    MS->>MRepo: markAllAsReadForReader(conversationId, recipientId, now)
    MRepo-->>MS: updated rows count
    MS-->>-MPMC: int
    MPMC-->>-Recipient: 204 No Content
Loading

7. Scheduled job: mentorship auto-completion at term end

MentorshipAutoCompletionScheduler (@Scheduled(cron=…), hourly UTC by default, @ConditionalOnProperty app.mentorships.auto-completion.enabled) fires MentorshipAutoCompletionService.autoCompleteExpired. The sweep query (MentorshipRepository.findActiveExpiredAt(ACTIVE, now)) runs outside any transaction; each matched mentorship is processed inside its own REQUIRES_NEW transaction via a TransactionTemplate so a single failing row cannot poison the rest of the sweep. completeOne flips status to COMPLETED, stamps terminatedAt, releases capacity, writes an audit row, and fires publishMentorshipAutoCompleted to both the mentor and the mentee.

sequenceDiagram
    participant Cron as Spring TaskScheduler
    participant Sch as MentorshipAutoCompletionScheduler
    participant Svc as MentorshipAutoCompletionService
    participant MRepo as MentorshipRepository
    participant ARepo as MentorshipAuditLogRepository
    participant NEP as NotificationEventPublisher
    actor Mentor
    actor Mentee

    Cron->>+Sch: tick (cron "0 0 * * * *", zone UTC)
    Sch->>+Svc: autoCompleteExpired()
    Svc->>MRepo: findActiveExpiredAt(ACTIVE, now)  [no tx]
    MRepo-->>Svc: List<Mentorship> expired
    alt empty
        Svc-->>Sch: 0
    end
    loop for each expired Mentorship m
        Note over Svc: TransactionTemplate.execute (REQUIRES_NEW)
        Svc->>Svc: completeOne(m.id, now)
        Svc->>MRepo: findById(m.id)
        alt status changed since sweep (raced by a manual /end)
            Svc-->>Svc: return (skip)
        else still ACTIVE
            Svc->>MRepo: save(mentorship.status=COMPLETED, terminatedAt=now, terminatedByUserId=null)
            Svc->>Svc: release mentee.activeMentorId + mentor.currentMenteeCount
            Svc->>ARepo: save(MentorshipAuditLog ACTIVE→COMPLETED "Auto-completed at end_date")
            Svc->>NEP: publishMentorshipAutoCompleted(mentorId, menteeFirstName)
            Svc->>NEP: publishMentorshipAutoCompleted(menteeId, mentorFirstName)
        end
        alt RuntimeException
            Note over Svc: per-row tx rolls back; sweep continues
        end
    end
    Svc-->>-Sch: processed count
    Sch-->>-Cron: void (logs if > 0)

    Note over NEP,Mentor: downstream NotificationEventListener<br/>persists Notification row and pushes FCM
    NEP-->>Mentor: in-app + push "Mentorship completed"
    NEP-->>Mentee: in-app + push "Mentorship completed"
Loading

8. User reports a feed post → admin resolves the report → optional ban

ReportController.submit calls ReportService.createReport, which validates target existence, rejects self-reports for USER targets, and INSERTs the row. Duplicate active reports are deduped by the Postgres partial-unique index uq_reports_active_per_reporter_target — the service catches the DataIntegrityViolationException, inspects the cause for the index name, and translates only that specific case to DuplicateReportException (HTTP 409). On success the service fires ReportSubmittedEvent; ReportFanoutListener (@TransactionalEventListener AFTER_COMMIT + @Async) reads userRepository.findAllAdminIds() and publishes a REPORT_RECEIVED notification to every admin (description is deliberately not forwarded — PII discipline). The admin transitions the report via AdminReportController.transition -> ReportService.updateStatus. If the moderator decides a ban is warranted they POST /api/admin/users/{userId}/ban against AdminController, which delegates to BanService.imposeAdminBan.

sequenceDiagram
    actor Reporter
    participant RCli as ReporterClient
    participant RC as ReportController
    participant RS as ReportService
    participant URepo as UserRepository
    participant MRepo as MentorshipRepository
    participant FPRepo as FeedPostRepository
    participant RRepo as ReportRepository
    participant AEP as ApplicationEventPublisher
    participant RFL as ReportFanoutListener
    participant NEP as NotificationEventPublisher
    actor Admin
    participant ACli as AdminClient
    participant ARC as AdminReportController
    participant AC as AdminController
    participant BS as BanService

    %% 1. Submit
    Reporter->>RCli: open report dialog on a post, submit
    RCli->>+RC: POST /api/reports {targetType=POST, targetId, problemType, description}
    RC->>+RS: createReport(reporterId, dto)
    alt self-report on USER target
        RS-->>RC: throw SelfReportException (400)
    end
    alt target=POST
        RS->>FPRepo: findByIdAndDeletedAtIsNull(targetId)
        alt missing
            RS-->>RC: throw ResourceNotFoundException (404)
        end
    else target=USER
        RS->>URepo: existsById(targetId)
    else target=MENTORSHIP
        RS->>MRepo: findById(targetId) + participant check
    end
    RS->>RRepo: save(Report{status=OPEN, createdAt=now})
    alt DataIntegrityViolation on uq_reports_active_per_reporter_target
        RS-->>RC: throw DuplicateReportException (409)
    end
    RS->>URepo: findById(reporterId) → firstName
    RS->>AEP: publishEvent(ReportSubmittedEvent{reportId, reporterId, reporterFirstName, targetType})
    Note over RS: NO description in the event — PII discipline
    RS-->>-RC: ReportResponse
    RC-->>-RCli: 201 Created
    Note over RS,RFL: TX commits → @Async AFTER_COMMIT
    RFL->>+RFL: onReportSubmitted(event)
    RFL->>URepo: findAllAdminIds()
    loop for each adminId
        RFL->>NEP: publishReportReceived(adminId, reporterFirstName, targetType)
    end
    RFL-->>-RFL: log fanout

    %% 2. Admin resolves
    Admin->>ACli: open queue, click "Resolve"
    ACli->>+ARC: PATCH /api/admin/reports/{id} {status: RESOLVED}
    ARC->>+RS: updateStatus(reportId, adminId, RESOLVED)
    RS->>RRepo: findById(reportId)
    RS->>RS: validateTransition(OPEN|UNDER_REVIEW → RESOLVED)
    alt invalid transition (terminal source)
        RS-->>ARC: throw InvalidReportTransitionException (400)
    end
    RS->>RRepo: save(report.status=RESOLVED, reviewedAt=now, reviewedById=adminId)
    Note over RRepo: @Version bumps; concurrent admin → 409
    RS-->>-ARC: ReportResponse
    ARC-->>-ACli: 200 OK

    %% 3. Optional ban (separate admin action)
    opt Admin decides to ban offender
        Admin->>ACli: click "Ban user"
        ACli->>+AC: POST /api/admin/users/{userId}/ban {reason, durationHours}
        AC->>+BS: imposeAdminBan(userId, adminId, reason, durationHours)
        BS->>BS: assert durationHours > 0
        BS->>URepo: findById(userId) + findById(adminId)
        BS->>BS: BanRepository.countNonLiftedByUserId + 1 → ordinal
        BS->>BS: BanRepository.save(Ban{source=ADMIN, expiresAt=now+hours})
        BS->>NEP: publishUserBanned(userId, expiresAt, reason, banCount)
        BS-->>-AC: Ban
        AC-->>-ACli: 200 OK BanResponse
    end
Loading
Team Members

Lab Reports


Weekly Meetings


Customer Meetings


Stakeholder Meetings


Project Requirements


🎦 Scenarios and Mock-ups

Use Case Diagrams

Class Diagram

Sequence Diagrams

Test Plan and Coverage


Project Standards


Final Milestone and MVP Plan

MVP Report


Final Milestone Report

Per-Member Prompt Logs


Future Work

Clone this wiki locally