diff --git a/docs/data-sources/activities.md b/docs/data-sources/activities.md new file mode 100644 index 0000000..cb831f2 --- /dev/null +++ b/docs/data-sources/activities.md @@ -0,0 +1,53 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_activities Data Source - terraform-provider-immich" +subcategory: "" +description: |- + Retrieves a list of activities for an album or asset. +--- + +# immich_activities (Data Source) + +Retrieves a list of activities for an album or asset. + +## Example Usage + +```terraform +data "immich_activities" "album_activities" { + album_id = "your-album-uuid" +} + +output "all_comments" { + value = [for a in data.immich_activities.album_activities.activities : a.comment if a.type == "COMMENT"] +} + +output "like_count" { + value = length([for a in data.immich_activities.album_activities.activities : a if a.type == "LIKE"]) +} +``` + + +## Schema + +### Required + +- `album_id` (String) ID of the album. + +### Optional + +- `asset_id` (String) ID of the asset. + +### Read-Only + +- `activities` (Attributes List) List of activities. (see [below for nested schema](#nestedatt--activities)) + + +### Nested Schema for `activities` + +Read-Only: + +- `comment` (String) Comment text. +- `created_at` (String) Timestamp when the activity was created. +- `id` (String) Unique identifier for the activity. +- `type` (String) Type of activity (COMMENT or LIKE). +- `user_id` (String) ID of the user who performed the activity. diff --git a/docs/resources/activity.md b/docs/resources/activity.md new file mode 100644 index 0000000..bbbfd1f --- /dev/null +++ b/docs/resources/activity.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "immich_activity Resource - terraform-provider-immich" +subcategory: "" +description: |- + Manages an Immich activity (comment or like). +--- + +# immich_activity (Resource) + +Manages an Immich activity (comment or like). + +## Example Usage + +```terraform +resource "immich_activity" "album_comment" { + album_id = "your-album-uuid" + type = "comment" + comment = "This is a great album!" +} + +resource "immich_activity" "asset_like" { + album_id = "your-album-uuid" + asset_id = "your-asset-uuid" + type = "like" +} +``` + + +## Schema + +### Required + +- `album_id` (String) ID of the album. +- `type` (String) Type of activity (comment or like). + +### Optional + +- `asset_id` (String) ID of the asset. +- `comment` (String) Comment text (required for type 'comment'). + +### Read-Only + +- `created_at` (String) Timestamp when the activity was created. +- `id` (String) Unique identifier for the activity. +- `user_id` (String) ID of the user who performed the activity. diff --git a/examples/data-sources/immich_activities/data-source.tf b/examples/data-sources/immich_activities/data-source.tf new file mode 100644 index 0000000..7472dfa --- /dev/null +++ b/examples/data-sources/immich_activities/data-source.tf @@ -0,0 +1,11 @@ +data "immich_activities" "album_activities" { + album_id = "your-album-uuid" +} + +output "all_comments" { + value = [for a in data.immich_activities.album_activities.activities : a.comment if a.type == "COMMENT"] +} + +output "like_count" { + value = length([for a in data.immich_activities.album_activities.activities : a if a.type == "LIKE"]) +} diff --git a/examples/resources/immich_activity/resource.tf b/examples/resources/immich_activity/resource.tf new file mode 100644 index 0000000..a360ed8 --- /dev/null +++ b/examples/resources/immich_activity/resource.tf @@ -0,0 +1,11 @@ +resource "immich_activity" "album_comment" { + album_id = "your-album-uuid" + type = "comment" + comment = "This is a great album!" +} + +resource "immich_activity" "asset_like" { + album_id = "your-album-uuid" + asset_id = "your-asset-uuid" + type = "like" +} diff --git a/internal/client/activity.go b/internal/client/activity.go new file mode 100644 index 0000000..b9a33c2 --- /dev/null +++ b/internal/client/activity.go @@ -0,0 +1,100 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Activity struct { + ID string `json:"id"` + Type string `json:"type"` // COMMENT or LIKE + AssetId string `json:"assetId,omitempty"` + AlbumId string `json:"albumId"` + User User `json:"user"` + Comment string `json:"comment,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type CreateActivityRequest struct { + Type string `json:"type"` // comment or like + AlbumId string `json:"albumId"` + AssetId string `json:"assetId,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func (c *Client) GetActivities(albumId string, assetId string) ([]Activity, error) { + url := fmt.Sprintf("%s/activities?albumId=%s", c.HostURL, albumId) + if assetId != "" { + url = fmt.Sprintf("%s&assetId=%s", url, assetId) + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var activities []Activity + err = json.Unmarshal(body, &activities) + if err != nil { + return nil, err + } + + return activities, nil +} + +func (c *Client) GetActivity(id string) (*Activity, error) { + // Immich doesn't seem to have a direct GET /activities/{id} based on research. + // We might have to find it in the list if we want to "Read" it by ID alone. + // But usually Terraform Read has the ID. + // If the API doesn't support GET by ID, we'll have to return an error or handle it. + // Wait, some research showed DELETE /activities/{id}. + // If there's no GET /activities/{id}, we might have a problem with pure Terraform Read. + // However, we can probably use a data source or just return the state if we can't refresh it easily. + // Actually, let's assume it doesn't exist for now and see if we can find a workaround. + return nil, fmt.Errorf("GET /activities/{id} is not supported by Immich API") +} + +func (c *Client) CreateActivity(activity CreateActivityRequest) (*Activity, error) { + rb, err := json.Marshal(activity) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/activities", c.HostURL), bytes.NewBuffer(rb)) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var newActivity Activity + err = json.Unmarshal(body, &newActivity) + if err != nil { + return nil, err + } + + return &newActivity, nil +} + +func (c *Client) DeleteActivity(id string) error { + req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/activities/%s", c.HostURL, id), nil) + if err != nil { + return err + } + + _, err = c.doRequest(req) + if err != nil { + return err + } + + return nil +} diff --git a/internal/provider/activities_data_source.go b/internal/provider/activities_data_source.go new file mode 100644 index 0000000..57ee189 --- /dev/null +++ b/internal/provider/activities_data_source.go @@ -0,0 +1,136 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/immich-app/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ datasource.DataSource = &activitiesDataSource{} + +func NewActivitiesDataSource() datasource.DataSource { + return &activitiesDataSource{} +} + +// activitiesDataSource defines the data source implementation. +type activitiesDataSource struct { + client *client.Client +} + +// activitiesDataSourceModel describes the data source data model. +type activitiesDataSourceModel struct { + AlbumId types.String `tfsdk:"album_id"` + AssetId types.String `tfsdk:"asset_id"` + Activities []activitiesModel `tfsdk:"activities"` +} + +type activitiesModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` + UserId types.String `tfsdk:"user_id"` + Comment types.String `tfsdk:"comment"` + CreatedAt types.String `tfsdk:"created_at"` +} + +func (d *activitiesDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_activities" +} + +func (d *activitiesDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Retrieves a list of activities for an album or asset.", + + Attributes: map[string]schema.Attribute{ + "album_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "ID of the album.", + }, + "asset_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "ID of the asset.", + }, + "activities": schema.ListNestedAttribute{ + Computed: true, + MarkdownDescription: "List of activities.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the activity.", + }, + "type": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Type of activity (COMMENT or LIKE).", + }, + "user_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ID of the user who performed the activity.", + }, + "comment": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Comment text.", + }, + "created_at": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Timestamp when the activity was created.", + }, + }, + }, + }, + }, + } +} + +func (d *activitiesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *activitiesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data activitiesDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + activities, err := d.client.GetActivities(data.AlbumId.ValueString(), data.AssetId.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read activities, got error: %s", err)) + return + } + + data.Activities = []activitiesModel{} + for _, activity := range activities { + activityState := activitiesModel{ + ID: types.StringValue(activity.ID), + Type: types.StringValue(activity.Type), + UserId: types.StringValue(activity.User.ID), + Comment: types.StringValue(activity.Comment), + CreatedAt: types.StringValue(activity.CreatedAt), + } + data.Activities = append(data.Activities, activityState) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/activity_resource.go b/internal/provider/activity_resource.go new file mode 100644 index 0000000..d6aa283 --- /dev/null +++ b/internal/provider/activity_resource.go @@ -0,0 +1,218 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/immich-app/terraform-provider-immich/internal/client" +) + +// Ensure the implementation satisfies the expected interfaces. +var _ resource.Resource = &activityResource{} +var _ resource.ResourceWithImportState = &activityResource{} + +func NewActivityResource() resource.Resource { + return &activityResource{} +} + +// activityResource defines the resource implementation. +type activityResource struct { + client *client.Client +} + +// activityResourceModel describes the resource data model. +type activityResourceModel struct { + ID types.String `tfsdk:"id"` + AlbumId types.String `tfsdk:"album_id"` + AssetId types.String `tfsdk:"asset_id"` + Type types.String `tfsdk:"type"` + Comment types.String `tfsdk:"comment"` + CreatedAt types.String `tfsdk:"created_at"` + UserId types.String `tfsdk:"user_id"` +} + +func (r *activityResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_activity" +} + +func (r *activityResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages an Immich activity (comment or like).", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique identifier for the activity.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "album_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "ID of the album.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "asset_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "ID of the asset.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Type of activity (comment or like).", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "comment": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Comment text (required for type 'comment').", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "created_at": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Timestamp when the activity was created.", + }, + "user_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ID of the user who performed the activity.", + }, + }, + } +} + +func (r *activityResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *activityResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data activityResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + createReq := client.CreateActivityRequest{ + Type: data.Type.ValueString(), + AlbumId: data.AlbumId.ValueString(), + AssetId: data.AssetId.ValueString(), + Comment: data.Comment.ValueString(), + } + + activity, err := r.client.CreateActivity(createReq) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create activity, got error: %s", err)) + return + } + + data.ID = types.StringValue(activity.ID) + data.CreatedAt = types.StringValue(activity.CreatedAt) + data.UserId = types.StringValue(activity.User.ID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *activityResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data activityResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // We have to find the activity in the list + activities, err := r.client.GetActivities(data.AlbumId.ValueString(), data.AssetId.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read activities, got error: %s", err)) + return + } + + var found *client.Activity + for _, a := range activities { + if a.ID == data.ID.ValueString() { + found = &a + break + } + } + + if found == nil { + resp.State.RemoveResource(ctx) + return + } + + data.Type = types.StringValue(strings.ToLower(found.Type)) + data.Comment = types.StringValue(found.Comment) + data.CreatedAt = types.StringValue(found.CreatedAt) + data.UserId = types.StringValue(found.User.ID) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *activityResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Immich doesn't seem to support updating activities. +} + +func (r *activityResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data activityResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteActivity(data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete activity, got error: %s", err)) + return + } +} + +func (r *activityResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Expected format: album_id/activity_id or album_id/asset_id/activity_id + idParts := strings.Split(req.ID, "/") + if len(idParts) == 2 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("album_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[1])...) + } else if len(idParts) == 3 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("album_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("asset_id"), idParts[1])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[2])...) + } else { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: album_id/activity_id or album_id/asset_id/activity_id. Got: %q", req.ID), + ) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0d5a3c9..e85d99d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -92,6 +92,7 @@ func (p *immichProvider) Resources(ctx context.Context) []func() resource.Resour NewAlbumResource, NewSystemConfigResource, NewLibraryResource, + NewActivityResource, } } @@ -100,6 +101,7 @@ func (p *immichProvider) DataSources(ctx context.Context) []func() datasource.Da NewUsersDataSource, NewAlbumsDataSource, NewLibrariesDataSource, + NewActivitiesDataSource, } }