nextcloud-activity 0.1.3

Nextcloud Activity API client and domain types
Documentation
//! Nextcloud Activity item model.
//!
//! Official documentation:
//! - [Activity API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html)
//! - [Developer manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst)
//! - [Activity endpoint specification](https://github.com/nextcloud/activity/blob/master/docs/endpoint-v2.md)

use serde::{Deserialize, Serialize};

use crate::wire::{lossy_opt_u64, lossy_u64};

/// A Nextcloud activity entry in the activity stream.
///
/// Returned in the OCS data payload from `GET /ocs/v2.php/apps/activity/api/v2/activity`.
///
/// See the [Nextcloud Activity API documentation](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#activity-element-fields)
/// and [developer_manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Activity {
    /// Unique auto-incremented database ID for the activity event.
    #[serde(deserialize_with = "lossy_u64")]
    pub activity_id: u64,

    /// Identifier of the application that generated this activity (e.g. `"files"`).
    pub app: String,

    /// Activity type identifier (corresponds to the JSON `type` field, e.g. `"file_created"`).
    #[serde(rename = "type", alias = "activity_type")]
    pub activity_type: String,

    /// User ID of the actor who performed the action (may be empty if generated by the system).
    #[serde(default)]
    pub user: String,

    /// Translated plain-text summary describing the activity.
    pub subject: String,

    /// Additional translated plain-text message or context, if available.
    #[serde(default)]
    pub message: Option<String>,

    /// ISO 8601 formatted timestamp when the activity took place.
    pub datetime: String,

    /// Full URL pointing to the resource or context associated with this activity.
    #[serde(default)]
    pub link: Option<String>,

    /// Category or type of the related target object (e.g. `"files"`).
    #[serde(default)]
    pub object_type: Option<String>,

    /// Numeric identifier of the related target object.
    #[serde(default, deserialize_with = "lossy_opt_u64")]
    pub object_id: Option<u64>,

    /// Name or path of the related target object (e.g. `"/test/hello.txt"`).
    #[serde(default)]
    pub object_name: Option<String>,
}

impl Activity {
    /// Creates a new `Activity` with the required core fields.
    pub fn new(
        activity_id: u64,
        app: impl Into<String>,
        activity_type: impl Into<String>,
        user: impl Into<String>,
        subject: impl Into<String>,
        datetime: impl Into<String>,
    ) -> Self {
        Self {
            activity_id,
            app: app.into(),
            activity_type: activity_type.into(),
            user: user.into(),
            subject: subject.into(),
            message: None,
            datetime: datetime.into(),
            link: None,
            object_type: None,
            object_id: None,
            object_name: None,
        }
    }

    /// Unique activity ID.
    pub const fn activity_id(&self) -> u64 {
        self.activity_id
    }

    /// App that generated the activity.
    pub fn app(&self) -> &str {
        &self.app
    }

    /// Activity type identifier.
    pub fn activity_type(&self) -> &str {
        &self.activity_type
    }

    /// User ID of the actor.
    pub fn user(&self) -> &str {
        &self.user
    }

    /// Plain-text subject.
    pub fn subject(&self) -> &str {
        &self.subject
    }

    /// Plain-text message, if set.
    pub fn message(&self) -> Option<&str> {
        self.message.as_deref()
    }

    /// ISO 8601 timestamp string.
    pub fn datetime(&self) -> &str {
        &self.datetime
    }

    /// Associated URL link, if set.
    pub fn link(&self) -> Option<&str> {
        self.link.as_deref()
    }

    /// Type of the related object, if set.
    pub fn object_type(&self) -> Option<&str> {
        self.object_type.as_deref()
    }

    /// Identifier of the related object, if set.
    pub const fn object_id(&self) -> Option<u64> {
        self.object_id
    }

    /// Name or path of the related object, if set.
    pub fn object_name(&self) -> Option<&str> {
        self.object_name.as_deref()
    }

    /// Sets the optional message text.
    #[must_use]
    pub fn with_message(mut self, message: impl Into<String>) -> Self {
        self.message = Some(message.into());
        self
    }

    /// Sets the optional link URL.
    #[must_use]
    pub fn with_link(mut self, link: impl Into<String>) -> Self {
        self.link = Some(link.into());
        self
    }

    /// Sets the optional object metadata (type, ID, and name).
    #[must_use]
    pub fn with_object(
        mut self,
        object_type: impl Into<String>,
        object_id: u64,
        object_name: impl Into<String>,
    ) -> Self {
        self.object_type = Some(object_type.into());
        self.object_id = Some(object_id);
        self.object_name = Some(object_name.into());
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_official_doc_example_json() {
        let json = r#"{
            "activity_id": 1,
            "datetime": "2015-11-20T12:49:31+00:00",
            "app": "files",
            "type": "file_created",
            "user": "test1",
            "subject": "test1 created hello.txt",
            "subject_rich": {
                "0": "{user1} created {file1}",
                "1": {
                    "user1": {
                        "type": "user",
                        "id": "test1",
                        "name": "Test User"
                    },
                    "file1": {
                        "type": "file",
                        "id": 23,
                        "name": "hello.txt",
                        "path": "/test/hello.txt"
                    }
                }
            },
            "icon": "https://cloud.example.com/apps/files/img/add-color.svg",
            "link": "https://cloud.example.com/apps/files/?dir=/test",
            "object_type": "files",
            "object_id": 23,
            "object_name": "/test/hello.txt",
            "previews": [
                {
                    "source": "https://cloud.example.com/core/preview.png?file=/hello.txt&x=150&y=150",
                    "link": "https://cloud.example.com/apps/files/?dir=/test&scrollto=hello.txt",
                    "mimeType": "text/plain",
                    "fileId": 23,
                    "view": "files",
                    "isMimeTypeIcon": false,
                    "filename": "hello.txt"
                }
            ]
        }"#;

        let activity: Activity = serde_json::from_str(json).expect("valid activity json");
        assert_eq!(activity.activity_id(), 1);
        assert_eq!(activity.datetime(), "2015-11-20T12:49:31+00:00");
        assert_eq!(activity.app(), "files");
        assert_eq!(activity.activity_type(), "file_created");
        assert_eq!(activity.user(), "test1");
        assert_eq!(activity.subject(), "test1 created hello.txt");
        assert_eq!(activity.message(), None);
        assert_eq!(
            activity.link(),
            Some("https://cloud.example.com/apps/files/?dir=/test")
        );
        assert_eq!(activity.object_type(), Some("files"));
        assert_eq!(activity.object_id(), Some(23));
        assert_eq!(activity.object_name(), Some("/test/hello.txt"));
    }

    #[test]
    fn parses_string_ids_and_optional_message() {
        let json = r#"{
            "activity_id": "42",
            "datetime": "2026-10-24T15:00:00+00:00",
            "app": "comments",
            "type": "comment_added",
            "user": "alice",
            "subject": "alice commented on report.pdf",
            "message": "Looks great to me!",
            "link": null,
            "object_type": "files",
            "object_id": "100",
            "object_name": "/report.pdf"
        }"#;

        let activity: Activity = serde_json::from_str(json).expect("valid activity json");
        assert_eq!(activity.activity_id(), 42);
        assert_eq!(activity.activity_type(), "comment_added");
        assert_eq!(activity.user(), "alice");
        assert_eq!(activity.message(), Some("Looks great to me!"));
        assert_eq!(activity.link(), None);
        assert_eq!(activity.object_id(), Some(100));
    }

    #[test]
    fn constructor_and_builder_methods() {
        let activity = Activity::new(
            7,
            "deck",
            "card_created",
            "bob",
            "bob created card",
            "2026-01-01T00:00:00Z",
        )
        .with_message("Card in Todo list")
        .with_link("https://cloud.example.com/apps/deck/#/boards/1")
        .with_object("deck_card", 55, "New Task");

        assert_eq!(activity.activity_id, 7);
        assert_eq!(activity.app, "deck");
        assert_eq!(activity.activity_type, "card_created");
        assert_eq!(activity.user, "bob");
        assert_eq!(activity.subject, "bob created card");
        assert_eq!(activity.datetime, "2026-01-01T00:00:00Z");
        assert_eq!(activity.message.as_deref(), Some("Card in Todo list"));
        assert_eq!(
            activity.link.as_deref(),
            Some("https://cloud.example.com/apps/deck/#/boards/1")
        );
        assert_eq!(activity.object_type.as_deref(), Some("deck_card"));
        assert_eq!(activity.object_id, Some(55));
        assert_eq!(activity.object_name.as_deref(), Some("New Task"));
    }
}