← All @molecule/* packages · App templates
@molecule/api-resource-activity-feedAPI resource · activity-feed · API (Node) · v1.0.1 · Apache-2.0
Activity feed and timeline with logging, feed queries, and unseen-count tracking for any resource
npm install @molecule/api-resource-activity-feed@molecule/api-resource-activity-feed is an API resource: the routes, validation and storage for activity-feed, built on the database and auth cores so it runs on whichever providers your app has bonded.
import { routes, requestHandlerMap } from '@molecule/api-resource-activity-feed'
// Wire routes into your Express app via mlcl inject
// POST /activities — log an activity
// GET /activities/feed — paginated user feed
// GET /activities/unseen — unseen count
// POST /activities/seen — mark seen up to ID
// GET /activities/:resourceType/:resourceId — resource timelineAuto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Activity feed resource for molecule.dev.
Activity timeline with logging, feed queries, resource timelines, and unseen-count tracking. Activities can reference any resource type.
import { routes, requestHandlerMap } from '@molecule/api-resource-activity-feed'
// Wire routes into your Express app via mlcl inject
// POST /activities — log an activity
// GET /activities/feed — paginated user feed
// GET /activities/unseen — unseen count
// POST /activities/seen — mark seen up to ID
// GET /activities/:resourceType/:resourceId — resource timeline
resource
npm install @molecule/api-resource-activity-feed @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource zod
ActivityA single activity entry in the feed.
interface Activity {
/** Unique activity identifier. */
id: string
/** The ID of the user who performed the action. */
actorId: string
/** The action that was performed (e.g. 'created', 'updated', 'commented'). */
action: string
/** The type of resource the action was performed on (e.g. 'post', 'project'). */
resourceType: string
/** The ID of the resource the action was performed on. */
resourceId: string
/** Optional metadata associated with the activity. */
metadata: Record<string, unknown> | null
/** When the activity was created (ISO 8601). */
createdAt: string
}
ActivitySeenStatusTracks the last-seen activity position for a user.
interface ActivitySeenStatus {
/** The user ID. */
userId: string
/** The ID of the last activity seen by this user. */
lastSeenActivityId: string
/** When this seen status was last updated (ISO 8601). */
updatedAt: string
}
CreateActivityInputInput for logging a new activity.
Note: this is the client-supplied payload — it deliberately omits actorId.
The actor is derived from the authenticated session in the service/handler
(actor = caller), never trusted from the request body, to prevent a user from
forging activities that impersonate another user.
interface CreateActivityInput {
/** The action that was performed. */
action: string
/** The type of resource the action was performed on. */
resourceType: string
/** The ID of the resource the action was performed on. */
resourceId: string
/** Optional metadata associated with the activity. */
metadata?: Record<string, unknown>
}
FeedQueryOptions for querying a user's activity feed.
interface FeedQuery extends PaginationOptions {
/** Filter to only activities involving this resource type. */
resourceType?: string
/** Filter to only activities with this action. */
action?: string
}
PaginatedResultA paginated result set.
interface PaginatedResult<T> {
/** The result items for the current page. */
data: T[]
/** Total number of matching items across all pages. */
total: number
/** Maximum number of results per page. */
limit: number
/** Number of results skipped. */
offset: number
}
PaginationOptionsOptions for paginated queries.
interface PaginationOptions {
/** Maximum number of results to return. */
limit?: number
/** Number of results to skip. */
offset?: number
}
feed(req, res)Retrieves the paginated activity feed for the authenticated user.
function feed(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with optional query params for filtering and pagination.res — The response object.getFeed(_userId, options)Retrieves a paginated activity feed for a user. Returns activities from all actors, optionally filtered by resource type or action.
function getFeed(_userId: string, options?: FeedQuery): Promise<PaginatedResult<Activity>>
_userId — The user requesting the feed (reserved for future per-user filtering).options — Query and pagination options.Returns: A paginated result of activities.
getTimeline(resourceType, resourceId, options)Retrieves a paginated timeline of activities for a specific resource.
function getTimeline(
resourceType: string,
resourceId: string,
options?: PaginationOptions,
): Promise<PaginatedResult<Activity>>
resourceType — The resource type to get the timeline for.resourceId — The resource ID to get the timeline for.options — Pagination options.Returns: A paginated result of activities.
getUnseenCount(userId)Returns the number of unseen activities for a user. An activity is unseen if it was created after the user's last-seen position, or if the user has never marked anything as seen.
function getUnseenCount(userId: string): Promise<number>
userId — The user ID.Returns: The count of unseen activities.
log(req, res)Logs a new activity to the feed.
function log(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with activity creation body.res — The response object.logActivity(actorId, data)Logs a new activity to the feed.
The actor is supplied explicitly by the caller (derived from the authenticated
session in the handler), never read from the client-supplied data, so a user
cannot forge activities impersonating another user.
function logActivity(actorId: string, data: CreateActivityInput): Promise<Activity>
actorId — The ID of the authenticated user who performed the action.data — The activity creation input (client-supplied, never includes the actor).Returns: The created activity.
markSeen(userId, upToId)Marks activities as seen for a user up to a given activity ID.
function markSeen(userId: string, upToId: string): Promise<void>
userId — The user marking activities as seen.upToId — The ID of the last activity seen.seen(req, res)Marks activities as seen for the authenticated user up to a given activity ID.
function seen(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with upToId in body.res — The response object.timeline(req, res)Retrieves the paginated activity timeline for a specific resource.
function timeline(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
req — The request with resourceType and resourceId params.res — The response object.unseen(_req, res)Returns the number of unseen activities for the authenticated user.
function unseen(_req: MoleculeRequest, res: MoleculeResponse): Promise<void>
_req — The request object (unused).res — The response object.createActivitySchemaSchema for validating activity creation input.
Note: actorId is intentionally absent — the actor is always derived from the
authenticated session in the handler (actor = caller), never accepted from the
client body. Adding it here would let any user forge feed entries impersonating
another user (broken access control). Mirror the comment/review/thread pattern.
const createActivitySchema: z.ZodObject<
{
action: z.ZodString
resourceType: z.ZodString
resourceId: z.ZodString
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>
},
z.core.$strip
>
markSeenSchemaSchema for validating mark-seen input.
const markSeenSchema: z.ZodObject<{ upToId: z.ZodString }, z.core.$strip>
requestHandlerMapHandler map for activity feed routes.
const requestHandlerMap: {
readonly logActivity: typeof log
readonly feed: typeof feed
readonly unseen: typeof unseen
readonly markSeen: typeof seen
readonly timeline: typeof timeline
}
routesRoutes for activity feed operations.
const routes: readonly [
{
readonly method: 'post'
readonly path: '/activities'
readonly handler: 'logActivity'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/activities/feed'
readonly handler: 'feed'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/activities/unseen'
readonly handler: 'unseen'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'post'
readonly path: '/activities/seen'
readonly handler: 'markSeen'
readonly middlewares: readonly ['authenticate']
},
{
readonly method: 'get'
readonly path: '/activities/:resourceType/:resourceId'
readonly handler: 'timeline'
},
]
Peer dependencies:
@molecule/api-database ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-logger ^1.0.1@molecule/api-resource ^1.0.1zod ^4.0.0@molecule/api-database
@molecule/api-i18n
@molecule/api-logger
@molecule/api-resource
zod
List endpoints return a PAGINATED envelope { data, total, limit, offset }, not a
bare array — read the rows off result.data (server). On the client, unwrapList(res)
from @molecule/app-http normalizes this envelope (pass it the whole HttpResponse), so
the rows come back; reading the response as a bare array — or res.data alone (which is
the envelope) — yields an EMPTY list.
Migration required. src/__setup__/activity-feeds.sql ships with this
package (two tables: activities, activity_seen_status) and must exist in
the target database before use (scaffolded apps apply it automatically).
The feed is GLOBAL, not per-user. getFeed() returns activities from ALL
actors (its userId param is reserved) — right for public/social timelines.
In an app with private data, do NOT mount GET /activities/feed as-is: it
exposes every user's actions to any authenticated user. Filter by
actor/resource in your own handler instead. Unseen counts are global too.
GET /activities/:resourceType/:resourceId (timeline) ships with NO auth
middleware — resource timelines are public by default; add an authorizer
when the underlying resources are private.
The actor is always the authenticated caller. POST /activities strips
any client-supplied actorId (feed-entry forgery guard). Keep that property
in custom paths — and prefer calling logActivity(actorId, data) directly
from your server-side domain handlers over round-tripping the HTTP route.
resourceType/resourceId are free-form (no FK): pick canonical type slugs
and reuse them across comments/bookmarks/reactions so timelines line up.
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
POST /activities,
or a server-side logActivity() call) records ONE entry whose actorId
is the session user, with the right action (verb), resourceType +
resourceId (the object acted on), and a createdAt timestamp — verify
by reading it back, not by trusting the 201.GET /activities/feed),
which is strictly reverse-chronological (createdAt desc); logging a
second activity pushes it above the first.getFeed() is GLOBAL, returning
every actor's activity (its userId param is reserved/unused), NOT a
per-user or following-based feed. Confirm that is the intent. If the app
holds ANY private or per-user data, GET /activities/feed as-is leaks it
(see privacy check) — replace it with an actor/resource-filtered handler.limit/offset back-to-back
and every activity appears exactly once (none skipped, none duplicated),
and total equals the real matching-row count. The resourceType/
action query filters narrow the feed to only matching entries.GET /activities/:resourceType/:resourceId returns ONLY that one
resource's timeline (reverse-chronological); an unrelated resource's
activity is absent from it.GET /activities/unseen
returns a count; POST /activities/seen { upToId } drops it to 0; a new
activity logged afterward raises it again. The last-seen position is
per-user (activity_seen_status keyed by userId) — mark seen as user A
and user B's unseen count is unaffected.POST /activities with an actorId in the body still records the SESSION user
as the actor (the schema strips it). Every endpoint fails closed for an
anonymous caller — feed, unseen, seen, AND the timeline route (which
ships with NO route middleware but 401s in-handler) all reject a
sessionless request.resourceId in the
timeline URL. If it does, add the actor/resource filter (feed) and an
authorizer (timeline) the remarks call for; the raw global endpoints are
not safe over private data.