MeetyyAPI
Documentation / API Reference / Activity Log

Activity Log API

The authenticated user's chronological history of what they did — and what was done to them — across posts, comments, mentions, communities, courses, connections, sessions and payments.

Every endpoint requires auth:sanctum and is scoped to $request->user(). There is no {user} segment and no admin variant: a token can only ever read or write its own history.


1. Design

Owner and actor are different columns

A row is addressed to an owner (user_id — whose log it appears in) and carries an actor (actor_id — who performed it). For your own actions the two are equal. For a mention they are not: the row lands in your log with the author as the actor, which is what lets one table answer both "what did I do" and "what happened to me".

This is the one structural difference from notifications, which models an inbox of things done to you and holds no record of what you did.

A new activity type is one enum case

activities stores what an activity points at polymorphically (subject, context) and what it renders with as JSON (properties). Nothing about a new activity needs a column, so adding one is:

  1. a case on App\Enums\Activity\ActivityType, with a label() and a category();
  2. one $this->recordActivity(...) call at the write site.

tests/Unit/Activity/ActivityTypeTest.php fails the build if a case is added without a label or without exactly one category, so the contract cannot be half-satisfied.

Rows are self-describing

properties carries the display payload captured at write time — the post excerpt, the community slug, the counterparty's name. Posts and comments get deleted and courses get renamed; a log that joined to live rows would render blanks or lie. It also keeps the list query free of polymorphic N+1s. Same reasoning as notifications.data and admin_audit_logs.changes.

The one thing deliberately not denormalised is the actor's name and avatar. Those resolve live through NotificationSenderResolver, so a user who changes their picture changes it everywhere, on the activity log and the notification inbox alike.

Logging can never break the thing it describes

ActivityLogger catches its own exceptions, reports through log_entry() and returns null. This is the opposite of AuditLogService, which must run inside its caller's transaction so a failed audit rolls back the moderation action — an unlogged admin action is not permitted to exist. Here the priority inverts: a missing history row is cosmetic, a post that fails to publish because of one is not. Pinned by a test that takes the activities table away and asserts the post still returns 201.

Key classes

Piece Path Role
ActivityType app/Enums/Activity/ActivityType.php The catalogue. String-backed domain.event values, first-person labels
ActivityCategory app/Enums/Activity/ActivityCategory.php The page's tabs. types() is derived from ActivityType::category(), so membership has one source
Activity app/Models/Activity/Activity.php The row. No updated_at; $dateFormat keeps milliseconds
ActivityLogger app/Services/Activity/ActivityLogger.php The only write path. Swallows its own failures by design
RecordsActivity app/Services/Activity/Concerns/RecordsActivity.php What services use. One line per hook, no constructor dependency
ActivityFeedService app/Services/Activity/ActivityFeedService.php Read side. Every query starts from forUser()
ActivityLinkBuilder app/Services/Activity/ActivityLinkBuilder.php Deep links; delegates to NotificationLinkBuilder for routes it already owns
RecordBookingActivity app/Listeners/Activity/RecordBookingActivity.php Booking activity, off the events the booking domain already emits

2. The catalogue

Tab (category) Types
posts post.created · post.shared · post.reacted
comments comment.created · comment.replied · comment.reacted
mentions mention.received
communities community.created · community.joined · community.join_requested · community.left · community.post_created · community.comment_created · community.level_up
courses course.created · course.published · course.enrolled · course.lesson_completed · course.quiz_passed · course.assignment_submitted · course.completed · course.reviewed
connections connection.requested · connection.accepted · connection.removed
sessions session.booked · session.confirmed · session.completed · session.cancelled
payments payment.completed · payment.refunded
account mentor.registered · mentor.approved

mention.received is one case for all four mentionable surfaces; properties.surface and subject.type say which. Adding a fifth surface needs no new case.

Deliberately absent: received reactions and post views. Both are high-volume, already covered by the notification inbox, and would bury the activity that matters. Each is one case away.

⚠️ There is no follow/unfollow in this application. Migration 2026_07_15_000001_rename_followers_visibility_to_connections replaced follows with symmetric connections. The connections tab is the follow-equivalent: connection.removed is the only record a disconnect leaves anywhere, because ConnectionService::remove() hard-deletes the edge and it carries no status trail.


3. Idempotency

Some activities are re-runnable and must still read as one thing the user did. Those are written through a dedupe_key, claimed with insertOrIgnore against unique(user_id, dedupe_key) — the same concurrency primitive NotificationDispatcher uses.

Activity Deduped on Because
post.reacted · comment.reacted the target Switching like → celebrate → like is one reaction
course.lesson_completed · course.quiz_passed enrolment + lesson Un-ticking and re-ticking is not a second completion
course.completed the enrolment The course:recalculate-enrollment-progress sweep lands in the same code path
course.enrolled · payment.* the enrolment / payment Stripe redelivers webhooks
session.* booking + lifecycle step The expiry sweep and a retry can reach the same transition
course.reviewed the review A review is an upsert; revising a rating is the same opinion
course.published · mentor.* the subject Unpublishing and republishing is one publication

course.assignment_submitted is deliberately not deduped — a resubmission is a real second attempt and the learner's history should show both.


4. Endpoints

GET /api/v1/activities

Newest first. All filters optional and combinable.

Parameter Example Notes
category ?category=courses One tab. 422 on an unknown value
type ?type=post.created,post.shared or ?type[]=post.created Comma list or array. Intersects with category
from / to ?from=2026-08-01&to=2026-08-31 Inclusive; to covers the whole day. 422 if from is later than to
per_page ?per_page=20 1–100, default config('constants.pagination.default') = 15
{
  "status": "success",
  "message": "Activities retrieved successfully.",
  "data": {
    "activities": {
      "data": [
        {
          "id": "9b1f…",
          "type": "mention.received",
          "type_label": "Was mentioned",
          "category": "mentions",
          "category_label": "Mentions",
          "actor": {
            "id": "8a2c…",
            "username": "sara",
            "full_name": "Sara Ali",
            "avatar_url": "https://…/sara.jpg",
            "is_self": false
          },
          "subject": { "type": "feed_post", "id": "7c3d…", "title": "Hey @you look at this" },
          "context": null,
          "properties": { "post_id": "7c3d…", "surface": "feed_post", "subject_title": "Hey @you look at this" },
          "url": "https://app.meetyy.test/feed/7c3d…",
          "created_at": "2026-09-02T10:11:12+00:00"
        }
      ],
      "links": { "first": "…", "last": "…", "prev": null, "next": null },
      "meta": { "current_page": 1, "from": 1, "last_page": 1, "per_page": 15, "to": 1, "total": 1 }
    }
  }
}

subject.type and context.type are stable short aliases (feed_post, community, course_lesson, …), never PHP class names — the column stores FQCNs because this application registers no morph map, but that is storage, not contract. actor is null for platform-driven rows; url is null where there is nowhere useful to send the reader.

GET /api/v1/activities/summary

Per-tab counts for the tab badges. Every category answers, including empty ones, so the client can render the row without knowing the catalogue.

{
  "status": "success",
  "message": "Activity summary retrieved successfully.",
  "data": {
    "summary": [
      { "category": "posts", "label": "Posts", "count": 12 },
      { "category": "comments", "label": "Comments", "count": 4 },
      { "category": "sessions", "label": "Sessions", "count": 0 }
    ],
    "total": 16
  }
}

DELETE /api/v1/activities/{id}

Removes one row from the caller's own log. 404 for a row belonging to anyone else — the service scopes through forUser() before it looks the row up, so an id from another user's log is indistinguishable from one that does not exist.

DELETE /api/v1/activities

Clears the caller's log. ?category=posts clears one tab. Answers with how many rows went:

{ "status": "success", "message": "Activity log cleared.", "data": { "deleted": 12 } }

The log is the user's own history, not evidence about them, which is why it has a delete path at all — admin_audit_logs, which is evidence, has none.


5. Retention

php artisan activities:prune deletes rows past config('constants.activity.retention_months') (default 12), scheduled monthly in routes/console.php beside notifications:prune-dispatches. Deletes in batches of 1000 so a platform-wide sweep does not hold locks long enough to be felt by everyone writing activity at that moment. --months= overrides the window for a one-off.


6. Adding an activity

  1. Add the ActivityType case, its label() and its category().
  2. use RecordsActivity in the service that owns the write, if it does not already.
  3. Call $this->recordActivity($user, ActivityType::YourCase, subject: …, properties: […]) — after the enclosing DB::transaction() where the method allows it, since the row is not worth risking the action over.
  4. Add a case to tests/Feature/Activity/ActivityRecordingTest.php driving the real endpoint.

No migration. If the activity can fire twice for the same fact, pass a dedupeKey.

Why the call is explicit rather than a model observer. Three blockers, not preferences: ConnectionService::remove() deletes through the query builder, which fires no model events at all; the mentions pivot has no model to hook; and the paths that settle enrolments, issue refunds and complete sessions run in queued jobs and the scheduler, where auth()->user() is null and the actor would be lost. Half the catalogue would need explicit calls regardless, and two mechanisms means "why didn't this log?" is answered in two places. The RecordsActivity trait removes the plumbing without the implicitness.