Compare commits

...

6 Commits

Author SHA1 Message Date
c49f03ec06 chore: add script to easy recreating/reseeding the database
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-05 13:22:49 -04:00
23ae78f6e1 chore: hide some warnings about public/internal api 2026-05-05 13:21:48 -04:00
0d4188b64e Add multi-workspace selector scope 2026-05-05 13:20:44 -04:00
78a7517de7 feat: add alpha preview brand badge 2026-05-05 13:19:33 -04:00
244be555f9 Add real workspace channels 2026-05-05 13:06:57 -04:00
6e658b8215 docs: add calendar integration spec 2026-05-05 13:02:14 -04:00
44 changed files with 3398 additions and 171 deletions

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
@@ -22,6 +23,7 @@ public class AppDbContext(
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Client> Clients => Set<Client>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
@@ -46,6 +48,7 @@ public class AppDbContext(
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureChannelsModule();
builder.ConfigureClientsModule();
builder.ConfigureCampaignsModule();
builder.ConfigureContentItemsModule();

View File

@@ -6,6 +6,7 @@ using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Channels.Data;
using Socialize.Api.Modules.Comments.Data;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
@@ -23,10 +24,14 @@ public static class DevelopmentSeedExtensions
{
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid AtlasWorkspaceId = Guid.Parse("11111111-1111-1111-1111-222222222222");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
private static readonly Guid ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
private static readonly Guid LumaInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000001");
private static readonly Guid LumaTikTokChannelId = Guid.Parse("33333333-3333-3333-3333-000000000002");
private static readonly Guid AtlasInstagramChannelId = Guid.Parse("33333333-3333-3333-3333-000000000003");
private static readonly Guid ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-555555555555");
@@ -252,7 +257,7 @@ public static class DevelopmentSeedExtensions
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Collective";
organization.Name = "Northstar Agency";
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(
@@ -309,32 +314,31 @@ public static class DevelopmentSeedExtensions
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = WorkspaceId,
Name = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = "Northstar Studio";
workspace.OrganizationId = OrganizationId;
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);
await UpsertWorkspaceAsync(
dbContext,
WorkspaceId,
OrganizationId,
managerUserId,
"Luma Coffee",
"America/Montreal",
"/images/seed/luma-coffee-logo.svg",
cancellationToken);
await UpsertWorkspaceAsync(
dbContext,
AtlasWorkspaceId,
OrganizationId,
managerUserId,
"Atlas Bakery",
"America/Montreal",
"/images/seed/atlas-bakery-logo.svg",
cancellationToken);
await UpsertClientAsync(
dbContext,
ScopedClientId,
"Luma Coffee",
"Active",
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
"/images/seed/luma-coffee-logo.svg",
"Sofia Martin",
"client@socialize.local",
WorkspaceId,
@@ -344,10 +348,10 @@ public static class DevelopmentSeedExtensions
HiddenClientId,
"Atlas Bakery",
"Active",
"https://images.unsplash.com/photo-1509440159596-0249088772ff?auto=format&fit=crop&w=200&q=80",
"/images/seed/atlas-bakery-logo.svg",
"Nina Cole",
"nina@atlasbakery.test",
WorkspaceId,
AtlasWorkspaceId,
cancellationToken);
await UpsertCampaignAsync(
@@ -365,7 +369,7 @@ public static class DevelopmentSeedExtensions
await UpsertCampaignAsync(
dbContext,
HiddenCampaignId,
WorkspaceId,
AtlasWorkspaceId,
HiddenClientId,
"Summer Retention",
"Planned",
@@ -375,6 +379,34 @@ public static class DevelopmentSeedExtensions
"Sequence email and paid social updates together.",
cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaInstagramChannelId,
WorkspaceId,
"Luma Coffee Instagram",
"Instagram",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
LumaTikTokChannelId,
WorkspaceId,
"Luma Coffee TikTok",
"TikTok",
"@lumacoffee",
null,
cancellationToken);
await UpsertChannelAsync(
dbContext,
AtlasInstagramChannelId,
AtlasWorkspaceId,
"Atlas Bakery Instagram",
"Instagram",
"@atlasbakery",
null,
cancellationToken);
await UpsertContentItemAsync(
dbContext,
ScopedContentItemId,
@@ -383,7 +415,7 @@ public static class DevelopmentSeedExtensions
ScopedCampaignId,
"Spring launch hero video",
"Fresh seasonal menu launch across Instagram and TikTok.",
"Instagram Reel, TikTok",
"Luma Coffee Instagram, Luma Coffee TikTok",
"In approval",
DateTimeOffset.UtcNow.AddDays(3),
"v3",
@@ -392,22 +424,22 @@ public static class DevelopmentSeedExtensions
await UpsertContentItemAsync(
dbContext,
HiddenContentItemId,
WorkspaceId,
AtlasWorkspaceId,
HiddenClientId,
HiddenCampaignId,
"Bakery loyalty carousel",
"Reward regular customers with a four-card retention carousel.",
"Instagram Carousel",
"Atlas Bakery Instagram",
"Draft",
DateTimeOffset.UtcNow.AddDays(10),
"v1",
1,
cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Instagram Reel, TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000001"), ScopedContentItemId, 1, "v1", "Spring launch hero video", "Initial draft for the seasonal menu launch.", "Luma Coffee Instagram, Luma Coffee TikTok", "Initial concept draft.", providerUserId, DateTimeOffset.UtcNow.AddDays(-5), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000002"), ScopedContentItemId, 2, "v2", "Spring launch hero video", "Updated hook and transitions after internal review.", "Luma Coffee Instagram, Luma Coffee TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000003"), ScopedContentItemId, 3, "v3", "Spring launch hero video", "Fresh seasonal menu launch across Instagram and TikTok.", "Luma Coffee Instagram, Luma Coffee TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), cancellationToken);
await EnsureRevisionAsync(dbContext, Guid.Parse("44444444-4444-4444-4444-000000000004"), HiddenContentItemId, 1, "v1", "Bakery loyalty carousel", "Reward regular customers with a four-card retention carousel.", "Atlas Bakery Instagram", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), cancellationToken);
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
if (asset is null)
@@ -535,6 +567,38 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertWorkspaceAsync(
AppDbContext dbContext,
Guid id,
Guid organizationId,
Guid ownerUserId,
string name,
string timeZone,
string logoUrl,
CancellationToken cancellationToken)
{
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (workspace is null)
{
workspace = new Workspace
{
Id = id,
Name = string.Empty,
TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Workspaces.Add(workspace);
}
workspace.Name = name;
workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone;
workspace.LogoUrl = logoUrl;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertClientAsync(
AppDbContext dbContext,
Guid id,
@@ -604,6 +668,37 @@ public static class DevelopmentSeedExtensions
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertChannelAsync(
AppDbContext dbContext,
Guid id,
Guid workspaceId,
string name,
string network,
string? handle,
string? externalUrl,
CancellationToken cancellationToken)
{
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == id, cancellationToken);
if (channel is null)
{
channel = new Channel
{
Id = id,
Name = string.Empty,
Network = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
}
channel.WorkspaceId = workspaceId;
channel.Name = name;
channel.Network = network;
channel.Handle = handle;
channel.ExternalUrl = externalUrl;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertContentItemAsync(
AppDbContext dbContext,
Guid id,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
WorkspaceId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Network = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Handle = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ExternalUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Channels_WorkspaceId",
table: "Channels",
column: "WorkspaceId");
migrationBuilder.CreateIndex(
name: "IX_Channels_WorkspaceId_Network_Name",
table: "Channels",
columns: new[] { "WorkspaceId", "Network", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Channels");
}
}
}

View File

@@ -491,6 +491,48 @@ namespace Socialize.Api.Migrations
b.ToTable("Campaigns", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Channels.Data.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("ExternalUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Handle")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Network")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("WorkspaceId");
b.HasIndex("WorkspaceId", "Network", "Name")
.IsUnique();
b.ToTable("Channels", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Channels.Data;
public class Channel
{
public Guid Id { get; init; }
public Guid WorkspaceId { get; set; }
public required string Name { get; set; }
public required string Network { get; set; }
public string? Handle { get; set; }
public string? ExternalUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Channels.Data;
public static class ChannelModelConfiguration
{
public static ModelBuilder ConfigureChannelsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Channel>(channel =>
{
channel.ToTable("Channels");
channel.HasKey(x => x.Id);
channel.Property(x => x.Name).HasMaxLength(256).IsRequired();
channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
channel.Property(x => x.Handle).HasMaxLength(256);
channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
channel.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
channel.HasIndex(x => x.WorkspaceId);
channel.HasIndex(x => new { x.WorkspaceId, x.Network, x.Name }).IsUnique();
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels;
public static class DependencyInjection
{
public static WebApplicationBuilder AddChannelsModule(
this WebApplicationBuilder builder)
{
return builder;
}
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Channels.Handlers;
public record ChannelDto(
Guid Id,
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,115 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record CreateChannelRequest(
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl);
public class CreateChannelRequestValidator
: Validator<CreateChannelRequest>
{
private static readonly string[] AllowedNetworks =
[
"Instagram",
"TikTok",
"Facebook",
"LinkedIn",
"YouTube",
"X",
"Reddit",
"Website",
];
public CreateChannelRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Network).NotEmpty().Must(network => AllowedNetworks.Contains(network))
.WithMessage("Selected network is invalid.");
RuleFor(x => x.Handle).MaximumLength(256);
RuleFor(x => x.ExternalUrl).MaximumLength(2048);
}
}
public class CreateChannelHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<CreateChannelRequest, ChannelDto>
{
public override void Configure()
{
Post("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(CreateChannelRequest request, CancellationToken ct)
{
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool workspaceExists = await dbContext.Workspaces
.AnyAsync(workspace => workspace.Id == request.WorkspaceId, ct);
if (!workspaceExists)
{
AddError(request => request.WorkspaceId, "The selected workspace does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedNetwork = request.Network.Trim();
string? normalizedHandle = request.Handle?.Trim();
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
bool duplicateChannel = await dbContext.Channels
.AnyAsync(
channel => channel.WorkspaceId == request.WorkspaceId
&& channel.Network == normalizedNetwork
&& channel.Name == normalizedName,
ct);
if (duplicateChannel)
{
AddError(request => request.Name, "A channel with this name already exists for the selected network.");
await SendErrorsAsync(StatusCodes.Status409Conflict, ct);
return;
}
Channel channel = new()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
Name = normalizedName,
Network = normalizedNetwork,
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Channels.Add(channel);
await dbContext.SaveChangesAsync(ct);
ChannelDto dto = new(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -0,0 +1,52 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
public record GetChannelsRequest(Guid? WorkspaceId);
public class GetChannelsHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<GetChannelsRequest, IReadOnlyCollection<ChannelDto>>
{
public override void Configure()
{
Get("/api/channels");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(GetChannelsRequest request, CancellationToken ct)
{
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(channel => channel.WorkspaceId == request.WorkspaceId.Value);
}
List<ChannelDto> channels = await query
.OrderBy(channel => channel.Network)
.ThenBy(channel => channel.Name)
.Select(channel => new ChannelDto(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.CreatedAt))
.ToListAsync(ct);
await SendOkAsync(channels, ct);
}
}

View File

@@ -10,6 +10,7 @@ using Socialize.Api.Infrastructure;
using Socialize.Api.Infrastructure.Development;
using Socialize.Api.Modules.Approvals;
using Socialize.Api.Modules.Assets;
using Socialize.Api.Modules.Channels;
using Socialize.Api.Modules.Clients;
using Socialize.Api.Modules.Comments;
using Socialize.Api.Modules.ContentItems;
@@ -65,6 +66,7 @@ builder.AddInfrastructureModule();
builder.AddIdentityModule();
builder.AddOrganizationsModule();
builder.AddWorkspaceModule();
builder.AddChannelsModule();
builder.AddClientsModule();
builder.AddCampaignsModule();
builder.AddContentItemsModule();

View File

@@ -11,7 +11,7 @@
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<WarningsAsErrors />
<NoWarn>CA2007</NoWarn> <!-- disable ConfigureAwait warning - not present in ASP.NET Core -->
<NoWarn>$(NoWarn);CA1515;CA2007</NoWarn> <!-- disable ConfigureAwait warning - not present in ASP.NET Core -->
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,249 @@
# Calendar Integrations
## Status
Draft
## Goal
Add `.ics` calendar import and export support so teams can plan social content with public holidays, observances, marketing moments, and personal work dates visible in the Content calendar.
The experience should feel familiar to users who already subscribe to calendars from Google Calendar, Android calendar apps, Apple Calendar, or Outlook.
## User Outcomes
- Organization admins can configure shared calendar sources that apply across the organization.
- Workspace admins can configure calendar sources for a specific workspace and see inherited organization sources.
- Users can configure private calendar sources for their own calendar view.
- Users can subscribe to a private Socialize `.ics` feed so their upcoming Socialize work appears in external calendar apps.
- Content planners can see dates such as Mother's Day, Christmas Eve, Ramadan, Queen's Day, public holidays, and marketing moments next to planned content.
- Users can search a curated catalog of trusted calendar sources instead of manually finding `.ics` URLs.
## Calendar Source Scopes
Calendar sources can exist at three scopes:
- `Organization`: managed by organization admins and inherited by workspaces in that organization.
- `Workspace`: managed by workspace admins/managers for one workspace.
- `User`: private to one user.
Organization calendars have an inheritance mode:
- `Required`: inherited by all workspaces and cannot be hidden at the workspace level.
- `Optional`: inherited by default, but workspace admins can hide the source for that workspace.
Workspace calendar settings must show inherited organization calendars as read-only sources. If an inherited source is optional, workspace settings may expose a workspace-level visibility override without allowing edits to the source itself.
User calendar settings show contextual organization and workspace sources plus private user sources. Private user calendars are visible only to that user.
## Permissions
- Organization admins can add, edit, remove, refresh, and set inheritance behavior for organization calendar sources.
- Workspace admins/managers can add, edit, remove, and refresh workspace calendar sources.
- Any authenticated user can add, edit, remove, refresh, and manage their own private calendar sources and exported feed.
- Client approvers and external collaborators can view shared calendar context in workspaces they can access but cannot manage shared sources.
- Private user calendar sources never become visible to other users.
## Curated Calendar Catalog
Socialize maintains a searchable curated catalog of calendar source metadata. The catalog presents trusted external sources through a polished product experience.
Catalog entries may point to providers such as:
- Google exported `.ics` calendars
- government public holiday feeds
- trusted holiday or observance APIs that can be represented as `.ics`
- other verified public `.ics` sources
Catalog metadata should include:
- title
- description
- country
- region or subdivision
- language
- category, such as public holiday, cultural observance, religious observance, or marketing moment
- culture or religion when relevant
- provider name
- source URL or provider configuration
- trust level or verification status
- default color
Initial curated catalog coverage should include:
- country and region public holidays
- major cultural and religious observances
- common social and marketing moments, such as Mother's Day, Valentine's Day, Black Friday, Cyber Monday, Pride Month, and Earth Day
Platform-specific social media event calendars are out of v1 unless they are added later as maintained catalog entries.
Curated calendars are added and removed as whole calendar sources. V1 does not require editing individual imported events from curated sources.
## Custom ICS Imports
V1 supports custom calendar import by URL subscription only.
One-time `.ics` file upload is deferred because URL subscriptions stay current and map better to the expected calendar-app mental model.
Each imported source stores:
- source scope
- source URL or catalog source reference
- display title
- color
- category
- enabled state
- last successful sync timestamp
- last attempted sync timestamp
- last sync error, when applicable
## Import Sync
Socialize fetches subscribed `.ics` calendars on a background schedule, for example every 6-12 hours.
Calendar settings also provide a manual refresh action for sources the current user can manage.
Imported events are normalized and stored for fast calendar rendering. Recurring events from the feed must be expanded into the date range needed by calendar views. The importer should support yearly observances and moving-date observances when the upstream feed provides them.
The importer should preserve useful event fields where available:
- title
- description
- start and end date/time
- all-day flag
- recurrence identity
- source event UID
- location
- source URL
- last modified timestamp
Imported events are read-only in Socialize.
## Timezones
All-day imported events remain attached to their calendar date and should not shift backward or forward because of timezone conversion.
Timed imported events follow these rules:
- If the event has a timezone, Socialize respects that timezone and displays the event at the equivalent local time for the viewer.
- If the event has no timezone, Socialize treats it as a floating local time.
- Floating times use the workspace timezone for organization and workspace calendars.
- Floating times use the user's timezone for private user calendars.
Exported user `.ics` feeds should use the user's timezone for display and include stable UTC timestamps for timed Socialize events where possible.
## Content Calendar Experience
Imported calendar events appear as read-only calendar context in the Content calendar.
They should:
- appear as all-day context entries when the source event is all-day
- use a distinct calendar-event style separate from Socialize content items
- be lower visual priority than actual content work on crowded days
- use the source calendar color
- be filterable by source, scope, and category
- never affect content status, approval state, deadlines, notifications, or workflow automation by themselves
Each calendar source has a configurable color.
- Curated catalog sources provide sensible default colors by category.
- Organization and workspace admins can change colors for shared calendars they manage.
- Users can change colors for private calendars.
- A later task may allow user-level display color overrides for inherited/shared sources.
## Calendar Source Control
The Content calendar includes a compact calendar source control.
The control lists currently displayed calendar sources, grouped or labeled by:
- Organization
- Workspace
- My calendars
Each source has a visibility toggle. Inherited read-only sources still appear in this list. The last entry is `Add calendar`.
The `Add calendar` flow lets users search the curated catalog or add a custom `.ics` URL, subject to their permissions and selected scope.
V1 focuses on source-level visibility. Per-event hiding is deferred.
## Content Creation From Events
Imported events are not editable as Socialize objects.
An imported event may provide a quick action such as `Create content for this date`. This opens the normal content creation flow with the planned date and event title prefilled.
## Content Detail And Date Picker Context
When a content item has a planned publish date, the content detail view should show compact calendar context pills near the publish date or scheduling area.
Examples:
- `Mother's Day`
- `Christmas Eve`
- `Ramadan`
- `Queen's Day`
Each pill comes from a visible calendar source. Clicking a pill opens a small event detail popover or the matching calendar day.
Calendar context pills are read-only hints. They do not automatically become content tags or hashtags.
The content create/edit date picker should lightly mark dates that have visible calendar events and show event names in a small date context panel.
Imported calendar events should not appear in unrelated dashboards or notification feeds in v1.
## User ICS Export
Each user can enable a private exported Socialize `.ics` subscription feed.
The feed:
- uses a private unguessable URL
- does not require login because calendar apps fetch it in the background
- can be regenerated or revoked by the user
- includes only that user's `my work` dates
- excludes imported holiday and observance events by default
`My work` includes:
- content items assigned to the user
- content items where the user is an approver
- content items the user created or owns, if ownership exists
- approval due dates
- planned publish dates
- campaign dates only when the user has access to that campaign
Feed entries should avoid sensitive discussion details. They may include:
- title
- date and time
- status
- workspace
- client
- campaign
- a link back to Socialize
## Localization
Imported event names are shown using the source-provided event name.
Curated catalog metadata may be localized, including calendar titles and descriptions.
## Out Of Scope For V1
- One-time `.ics` file upload
- Editing imported events in Socialize
- Per-event hide controls
- Automatic scheduling warnings or blocked dates
- Automatic hashtag creation from calendar events
- Imported event notifications
- Platform-specific event calendars unless maintained as curated sources
- Full holiday-rule engine maintained manually inside Socialize
## Open Questions
- Which initial countries, regions, cultures, religions, and marketing calendars should ship in the first curated catalog?
- Which backend library should be used for robust `.ics` parsing and recurrence expansion?
- What default sync cadence should be used in production?
- Should user-level color overrides for shared calendars be included in v1 or deferred?

43
docs/FEATURES/channels.md Normal file
View File

@@ -0,0 +1,43 @@
# Channels
Channels are configured social destinations inside a workspace. They represent the account, handle, page, feed, newsletter, or other publication destination where content will eventually be handed off for publishing.
Channels are workspace-owned data. Organization-owned connectors may provide credentials for external systems, but the workspace owns which destinations are available for content planning.
## Model
A channel has:
- `id`
- `workspaceId`
- `name`
- `network`
- optional `handle`
- optional `externalUrl`
- `createdAt`
`network` is a controlled string matching the frontend channel network options:
- `Instagram`
- `TikTok`
- `Facebook`
- `LinkedIn`
- `YouTube`
- `X`
- `Reddit`
- `Website`
Channel names must be unique inside a workspace for the same network.
## Behavior
- Authenticated users with workspace access can list channels for their active workspace.
- Workspace managers can create channels.
- Content planning uses configured channels as selectable destinations.
- Development seed data should create real workspace channels instead of relying on content target labels as fake channels.
## Not In Scope
- External connector credentials.
- Publishing directly to social networks.
- Channel deletion, archiving, or editing.

View File

@@ -0,0 +1,19 @@
# Task: Add alpha preview brand badge
## Goal
Add a compact product-stage badge beside the authenticated app brand and public landing brand so users can see the product is in alpha preview without treating it as an error or blocking alert. Remove the old authenticated sidebar caption so the brand treatment stays focused.
## Relevant Files
- `frontend/src/layouts/main/AppSidebar.vue`
- `frontend/src/static/components/LandingSiteMenu.vue`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,38 @@
# Task: Backend calendar source foundation
## Goal
Add backend storage and APIs for organization, workspace, and user calendar sources.
## Feature Spec
- `docs/FEATURES/calendar-integrations.md`
## Scope
- Add a calendar integrations module under `backend/src/Socialize.Api/Modules/CalendarIntegrations`.
- Model calendar sources for organization, workspace, and user scopes.
- Support organization inheritance modes: required and optional.
- Track source URL, catalog reference, display title, color, category, enabled state, and sync metadata.
- Add endpoints to list visible sources for a workspace/user context.
- Add endpoints to create/update/delete sources according to scope permissions.
- Show inherited organization sources in workspace responses as read-only.
- Add validation for source URLs, colors, scope, and inheritance mode.
- Add focused backend tests for permissions and inherited source visibility.
## Relevant Files
- `backend/src/Socialize.Api/Modules/CalendarIntegrations/`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Program.cs`
- `backend/tests/Socialize.Tests/`
- `shared/openapi/openapi.json`
- `frontend/src/api/schema.d.ts`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
./scripts/update-openapi.sh
```

View File

@@ -0,0 +1,37 @@
# Task: Curated catalog and ICS import sync
## Goal
Add searchable curated calendar catalog metadata and background `.ics` import sync.
## Feature Spec
- `docs/FEATURES/calendar-integrations.md`
## Scope
- Add curated calendar catalog storage or seed data for trusted external calendar sources.
- Add catalog search/filter APIs by country, region, language, category, culture/religion, and provider.
- Add `.ics` fetch, parse, recurrence expansion, and normalized event storage.
- Support manual refresh for manageable sources.
- Track last attempted sync, last successful sync, and last sync error.
- Preserve all-day events without timezone date shifting.
- Respect timezone-bearing timed events and floating timed events according to the feature spec.
- Add backend tests for parsing, recurrence expansion, timezone behavior, and sync error handling.
## Relevant Files
- `backend/src/Socialize.Api/Modules/CalendarIntegrations/`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/tests/Socialize.Tests/`
- `docs/FEATURES/calendar-integrations.md`
- `shared/openapi/openapi.json`
- `frontend/src/api/schema.d.ts`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
./scripts/update-openapi.sh
```

View File

@@ -0,0 +1,36 @@
# Task: Content calendar UI integration
## Goal
Show imported calendar events and calendar source controls in the Content calendar.
## Feature Spec
- `docs/FEATURES/calendar-integrations.md`
## Scope
- Add calendar source data loading to the Content feature.
- Render imported events as read-only calendar context entries in Month, Week, and Upcoming views.
- Style imported events distinctly from Socialize content items and apply source colors.
- Add a calendar source control grouped by Organization, Workspace, and My calendars.
- Add visibility toggles for displayed sources.
- Add `Add calendar` as the last source-control entry.
- Add an add-calendar flow that supports curated catalog search and custom `.ics` URL subscriptions according to user permissions.
- Keep imported events lower visual priority than actual content work on crowded days.
- Add a quick action to create content from an imported event, prefilled with date and event title.
## Relevant Files
- `frontend/src/features/content/views/ContentItemsView.vue`
- `frontend/src/features/content/`
- `frontend/src/plugins/api.js`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,34 @@
# Task: Content date context UI
## Goal
Show calendar context while creating, editing, and viewing content items.
## Feature Spec
- `docs/FEATURES/calendar-integrations.md`
## Scope
- Show compact read-only calendar context pills near the publish date or scheduling area on content detail.
- Include same-day visible calendar events such as Mother's Day, Christmas Eve, Ramadan, and Queen's Day.
- Let users click a pill to open event details or navigate to the matching calendar day.
- Lightly mark dates with visible calendar events in the content create/edit date picker.
- Show event names in a small date context panel during date selection.
- Do not convert calendar context pills into hashtags or content tags automatically.
- Do not add scheduling warnings, blocking behavior, or notifications in this task.
## Relevant Files
- `frontend/src/features/content/views/ContentItemDetailView.vue`
- `frontend/src/features/content/views/ContentItemsView.vue`
- `frontend/src/features/content/`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,40 @@
# Task: User ICS export feed
## Goal
Let each user subscribe to a private `.ics` feed containing their Socialize `my work` dates.
## Feature Spec
- `docs/FEATURES/calendar-integrations.md`
## Scope
- Add backend support for user export feed tokens.
- Generate private unguessable feed URLs that do not require login.
- Let users enable, revoke, and regenerate their export URL.
- Emit valid `.ics` containing only the user's `my work` dates.
- Include assigned content, approval work, owned/created content where available, approval due dates, planned publish dates, and accessible campaign dates.
- Exclude imported holiday and observance events by default.
- Avoid sensitive comments or approval discussion details in exported entries.
- Add a user settings UI for copying, revoking, and regenerating the feed URL.
- Add backend tests for feed authorization boundary, token regeneration, and event contents.
## Relevant Files
- `backend/src/Socialize.Api/Modules/CalendarIntegrations/`
- `backend/src/Socialize.Api/Modules/ContentItems/`
- `backend/src/Socialize.Api/Modules/Approvals/`
- `frontend/src/features/user-profile-settings/`
- `frontend/src/config.js`
- `backend/tests/Socialize.Tests/`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend
npm run build
./scripts/update-openapi.sh
```

View File

@@ -0,0 +1,23 @@
# Task: Add content detail back navigation
## Goal
Make it easy to return to the Content calendar or upcoming list after opening a content item detail page.
## Scope
- Add a visible back control to `ContentItemDetailView`.
- Preserve the originating Content page view state when navigating from calendar or upcoming entries.
- Keep the change frontend-only.
## Relevant Files
- `frontend/src/features/content/views/ContentItemsView.vue`
- `frontend/src/features/content/views/ContentItemDetailView.vue`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,34 @@
# Task: Add real workspace channels
## Feature
`docs/FEATURES/channels.md`
## Goal
Replace frontend-derived fake channels with real workspace-owned channel records served by the backend API.
## Scope
- Add a backend `Channels` module with a `Channel` table.
- Add list and create endpoints for workspace channels.
- Seed development channels and align seeded content publication targets to those configured channel names.
- Update the frontend channels store to load and create channels through the API.
- Keep the Channels page UI shape intact.
## Relevant Files
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/Channels/`
- `backend/src/Socialize.Api/Infrastructure/Development/DevelopmentSeedExtensions.cs`
- `frontend/src/features/channels/stores/channelsStore.js`
- `docs/FEATURES/channels.md`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend
npm run build
```

View File

@@ -0,0 +1,33 @@
# All Workspaces Selector
## Feature
Workspace navigation and cross-workspace content visibility.
## Goal
Allow users with access to multiple workspaces to select an "All Workspaces" scope from the workspace selector and view combined workspace data in list/calendar style views.
## Scope
- Add an explicit all-workspaces selection state to the frontend workspace store.
- Add an "All Workspaces" entry as the first workspace selector item.
- Add per-workspace visibility toggles for the all-workspaces aggregate scope.
- Fetch list data without `workspaceId` when all workspaces are selected.
- Filter all-workspaces list data to the currently visible workspace set.
- Keep creation and workspace settings actions scoped to a concrete workspace.
## Likely Files
- `frontend/src/features/workspaces/stores/workspaceStore.js`
- `frontend/src/layouts/main/WorkspaceSelector.vue`
- `frontend/src/features/content/stores/contentItemsStore.js`
- `frontend/src/features/campaigns/stores/campaignsStore.js`
- `frontend/src/features/clients/stores/clientsStore.js`
- `frontend/src/features/channels/stores/channelsStore.js`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Validation
- `cd frontend && npm run build`

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Atlas Bakery logo</title>
<desc id="desc">A bakery wheat and loaf mark in rose and golden tones.</desc>
<rect width="256" height="256" rx="56" fill="#fff7ed"/>
<circle cx="128" cy="128" r="92" fill="#fffaf2" stroke="#9f1239" stroke-width="10"/>
<path d="M73 157c15-38 42-58 82-58 29 0 51 16 62 46 4 12-5 25-18 25H87c-11 0-18-7-14-13z" fill="#d97706"/>
<path d="M92 145c11-17 25-26 42-26M127 145c11-17 25-26 42-26M162 145c9-13 18-20 29-21" fill="none" stroke="#fffaf2" stroke-width="9" stroke-linecap="round"/>
<path d="M78 77c34 13 52 45 50 96" fill="none" stroke="#0f766e" stroke-width="8" stroke-linecap="round"/>
<path d="M75 78c18-18 36-15 48 6-20 9-36 7-48-6zM92 105c21-13 38-6 46 18-22 4-37-2-46-18z" fill="#0f766e"/>
<path d="M134 77c15-21 33-23 53-7-11 17-29 20-53 7zM132 109c20-14 38-9 51 14-18 9-36 4-51-14z" fill="#16a34a"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Luma Coffee logo</title>
<desc id="desc">A warm coffee cup monogram inside a cream roundel.</desc>
<rect width="256" height="256" rx="56" fill="#f8fafc"/>
<circle cx="128" cy="128" r="92" fill="#fffaf2" stroke="#172033" stroke-width="10"/>
<path d="M82 104h78v38c0 26-18 45-43 45S74 168 74 142v-30a8 8 0 018-8z" fill="#8b4a2f"/>
<path d="M160 116h16c18 0 30 11 30 27s-12 27-30 27h-20v-18h20c8 0 13-4 13-9s-5-9-13-9h-16v-18z" fill="#8b4a2f"/>
<path d="M93 122h18v43h34v18H93v-61z" fill="#fffaf2"/>
<path d="M98 72c0-15 14-17 14-30M130 72c0-15 14-17 14-30M162 72c0-15 14-17 14-30" fill="none" stroke="#0f766e" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">Northstar Agency logo</title>
<desc id="desc">A navy square mark with a teal north star and orange accent.</desc>
<rect width="256" height="256" rx="56" fill="#172033"/>
<circle cx="128" cy="128" r="74" fill="#fffaf2" opacity="0.08"/>
<path d="M128 34l22.8 70.2H224l-59.2 43 22.6 69.8L128 173.8 68.6 217l22.6-69.8L32 104.2h73.2L128 34z" fill="#fffaf2"/>
<path d="M128 62l14.6 45h47.4l-38.4 27.9 14.8 45.5L128 152.3l-38.4 28.1 14.8-45.5L66 107h47.4L128 62z" fill="#0f766e"/>
<circle cx="188" cy="68" r="15" fill="#ff8a3d"/>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -788,6 +788,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/channels": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesChannelsHandlersGetChannelsHandler"];
put?: never;
post: operations["SocializeApiModulesChannelsHandlersCreateChannelHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/campaigns": {
parameters: {
query?: never;
@@ -1411,6 +1427,27 @@ export interface components {
primaryContactEmail?: string | null;
primaryContactPortraitUrl?: string | null;
};
SocializeApiModulesChannelsHandlersChannelDto: {
/** Format: guid */
id?: string;
/** Format: guid */
workspaceId?: string;
name?: string;
network?: string;
handle?: string | null;
externalUrl?: string | null;
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesChannelsHandlersCreateChannelRequest: {
/** Format: guid */
workspaceId: string;
name: string;
network: string;
handle?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */
id?: string;
@@ -3426,6 +3463,75 @@ export interface operations {
};
};
};
SocializeApiModulesChannelsHandlersGetChannelsHandler: {
parameters: {
query?: {
workspaceId?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesChannelsHandlersCreateChannelHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersCreateChannelRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersChannelDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
parameters: {
query?: {

View File

@@ -15,7 +15,7 @@ export const useCampaignsStore = defineStore('campaigns', () => {
const error = ref(null);
async function fetchCampaigns() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
if (!authStore.isAuthenticated) {
campaigns.value = [];
error.value = null;
return;
@@ -27,11 +27,13 @@ export const useCampaignsStore = defineStore('campaigns', () => {
try {
const response = await client.get('/api/campaigns', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
},
});
campaigns.value = response.data ?? [];
campaigns.value = (response.data ?? []).filter(campaign =>
workspaceStore.isWorkspaceVisible(campaign.workspaceId)
);
} catch (fetchError) {
console.error('Failed to fetch campaigns:', fetchError);
campaigns.value = [];
@@ -75,9 +77,9 @@ export const useCampaignsStore = defineStore('campaigns', () => {
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
campaigns.value = [];
error.value = null;
return;

View File

@@ -1,52 +1,20 @@
import { computed } from 'vue';
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useSessionStorage } from '@vueuse/core';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useClient } from '@/plugins/api.js';
export const useChannelsStore = defineStore('channels', () => {
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const contentItemsStore = useContentItemsStore();
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
serializer: {
read: value => (value ? JSON.parse(value) : {}),
write: value => JSON.stringify(value ?? {}),
},
});
const client = useClient();
const channels = computed(() => {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
return [];
}
const derivedChannels = new Map();
const customChannels = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
for (const item of contentItemsStore.items) {
for (const name of parseTargets(item.publicationTargets)) {
const key = normalizeChannelKey(name);
const existing = derivedChannels.get(key) ?? {
id: key,
name,
network: null,
source: 'derived',
};
derivedChannels.set(key, existing);
}
}
for (const channel of customChannels) {
derivedChannels.set(channel.id, {
...channel,
source: 'custom',
});
}
return [...derivedChannels.values()].sort((left, right) => left.name.localeCompare(right.name));
});
const channels = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const error = ref(null);
const loadedWorkspaceId = ref(null);
const allWorkspacesKey = '__all__';
const availableNetworks = [
'Instagram',
@@ -59,64 +27,105 @@ export const useChannelsStore = defineStore('channels', () => {
'Website',
];
function createChannel(payload) {
async function fetchChannels({ force = false } = {}) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
const currentScopeKey = currentWorkspaceId ?? workspaceStore.workspaceScopeKey ?? allWorkspacesKey;
if (!authStore.isAuthenticated) {
channels.value = [];
error.value = null;
loadedWorkspaceId.value = null;
return;
}
if (!force && loadedWorkspaceId.value === currentScopeKey) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/channels', {
params: {
workspaceId: currentWorkspaceId ?? undefined,
},
});
channels.value = (response.data ?? []).filter(channel =>
workspaceStore.isWorkspaceVisible(channel.workspaceId)
);
loadedWorkspaceId.value = currentScopeKey;
} catch (fetchError) {
console.error('Failed to fetch channels:', fetchError);
channels.value = [];
loadedWorkspaceId.value = null;
error.value = 'Failed to load channels.';
} finally {
isLoading.value = false;
}
}
async function createChannel(payload) {
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
if (!currentWorkspaceId) {
if (!authStore.isAuthenticated || !currentWorkspaceId) {
throw new Error('An active workspace is required to create a channel.');
}
const normalizedName = payload.name.trim();
const normalizedNetwork = payload.network.trim();
if (!normalizedName) {
throw new Error('Channel name is required.');
if (isCreating.value) {
throw new Error('A channel creation request is already in progress.');
}
if (!normalizedNetwork) {
throw new Error('Network is required.');
}
isCreating.value = true;
error.value = null;
if (!availableNetworks.includes(normalizedNetwork)) {
throw new Error('Selected network is invalid.');
}
try {
const response = await client.post('/api/channels', {
...payload,
workspaceId: currentWorkspaceId,
});
const existing = channels.value.some(channel =>
channel.name.toLowerCase() === normalizedName.toLowerCase()
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
);
if (existing) {
throw new Error('A channel with this name already exists for the selected network.');
}
if (response.data) {
channels.value = [...channels.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
}
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
customChannelsByWorkspace.value = {
...customChannelsByWorkspace.value,
[currentWorkspaceId]: [
...next,
{
id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`),
name: normalizedName,
network: normalizedNetwork,
},
],
};
return response.data;
} catch (createError) {
console.error('Failed to create channel:', createError);
const message = createError.response?.data?.errors?.[0]?.reason
?? createError.response?.data?.message
?? 'Failed to create channel.';
error.value = message;
throw new Error(message);
} finally {
isCreating.value = false;
}
}
function parseTargets(value) {
return (value ?? '')
.split(/[,\n]+/)
.map(target => target.trim())
.filter(Boolean);
}
watch(
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
channels.value = [];
error.value = null;
loadedWorkspaceId.value = null;
return;
}
function normalizeChannelKey(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
await fetchChannels();
},
{ immediate: true }
);
return {
availableNetworks,
channels,
isLoading,
isCreating,
error,
fetchChannels,
createChannel,
};
});

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
@@ -47,10 +47,12 @@
.filter(channel => channel.network)
.map(channel => {
const metrics = buildMetrics(channel.name);
const workspace = workspaceStore.workspaces.find(candidate => candidate.id === channel.workspaceId);
return {
...channel,
...metrics,
workspaceName: workspace?.name ?? t('nav.noWorkspace'),
};
})
);
@@ -87,11 +89,11 @@
isCreateFormVisible.value = true;
}
function submitForm() {
async function submitForm() {
formError.value = null;
try {
channelsStore.createChannel({
await channelsStore.createChannel({
name: form.name,
network: form.network,
});
@@ -118,6 +120,10 @@
},
{ immediate: true }
);
onMounted(() => {
channelsStore.fetchChannels();
});
</script>
<template>
@@ -178,15 +184,30 @@
<button
class="primary"
type="button"
:disabled="channelsStore.isCreating"
@click="submitForm"
>
{{ t('channels.createTitle') }}
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
</button>
</div>
</div>
<div
v-if="channelsForActiveNetwork.length"
v-if="channelsStore.isLoading"
class="page-message"
>
{{ t('loading') }}
</div>
<div
v-else-if="channelsStore.error"
class="page-message error"
>
{{ channelsStore.error }}
</div>
<div
v-else-if="channelsForActiveNetwork.length"
class="channel-grid"
>
<article
@@ -196,7 +217,7 @@
>
<div class="channel-header">
<strong>{{ channel.name }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<span>{{ channel.workspaceName }}</span>
</div>
<div class="channel-metrics">

View File

@@ -25,7 +25,7 @@ export const useClientsStore = defineStore('clients', () => {
});
async function fetchClients() {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
if (!authStore.isAuthenticated) {
clients.value = [];
error.value = null;
return;
@@ -37,11 +37,13 @@ export const useClientsStore = defineStore('clients', () => {
try {
const response = await client.get('/api/clients', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
},
});
clients.value = response.data ?? [];
clients.value = (response.data ?? []).filter(candidate =>
workspaceStore.isWorkspaceVisible(candidate.workspaceId)
);
} catch (fetchError) {
console.error('Failed to fetch clients:', fetchError);
clients.value = [];
@@ -153,9 +155,9 @@ export const useClientsStore = defineStore('clients', () => {
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
clients.value = [];
error.value = null;
return;

View File

@@ -24,6 +24,10 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
status: false,
});
function currentItemWorkspaceId() {
return item.value?.workspaceId ?? workspaceStore.activeWorkspaceId;
}
function reset() {
item.value = null;
revisions.value = [];
@@ -54,7 +58,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
client.get('/api/approvals', { params: { contentItemId } }),
client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
contentItemId,
},
}),
@@ -97,7 +101,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
const response = await client.post('/api/assets/google-drive', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: currentItemWorkspaceId(),
});
if (response.data) {
assets.value = [...assets.value, response.data];
@@ -131,7 +135,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
const response = await client.post('/api/comments', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: currentItemWorkspaceId(),
});
if (response.data) {
comments.value = [...comments.value, response.data];
@@ -202,7 +206,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
async function fetchNotifications(contentItemId) {
const response = await client.get('/api/notifications', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: currentItemWorkspaceId() ?? undefined,
contentItemId,
},
});

View File

@@ -20,7 +20,7 @@ export const useContentItemsStore = defineStore('content-items', () => {
);
async function fetchContentItems(filters = {}) {
if (!authStore.isAuthenticated || !workspaceStore.activeWorkspaceId) {
if (!authStore.isAuthenticated) {
items.value = [];
error.value = null;
return;
@@ -32,13 +32,15 @@ export const useContentItemsStore = defineStore('content-items', () => {
try {
const response = await client.get('/api/content-items', {
params: {
workspaceId: workspaceStore.activeWorkspaceId,
workspaceId: workspaceStore.activeWorkspaceId ?? undefined,
clientId: filters.clientId,
campaignId: filters.campaignId,
},
});
items.value = response.data ?? [];
items.value = (response.data ?? []).filter(item =>
workspaceStore.isWorkspaceVisible(item.workspaceId)
);
} catch (fetchError) {
console.error('Failed to fetch content items:', fetchError);
items.value = [];
@@ -86,9 +88,9 @@ export const useContentItemsStore = defineStore('content-items', () => {
}
watch(
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
async ([isAuthenticated, workspaceId]) => {
if (!isAuthenticated || !workspaceId) {
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => {
if (!isAuthenticated) {
items.value = [];
error.value = null;
return;

View File

@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core';
import { mdiArrowLeft } from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
@@ -332,6 +333,23 @@
commentForm.body = '';
}
async function navigateBackToContent() {
const returnTo = typeof route.query.returnTo === 'string' ? route.query.returnTo : '';
const previousPath = router.options.history.state.back;
if (returnTo.startsWith('/app/content')) {
await router.push(returnTo);
return;
}
if (typeof previousPath === 'string' && previousPath.startsWith('/app/content')) {
router.back();
return;
}
await router.push({ name: 'content-items' });
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
@@ -373,6 +391,15 @@
<template>
<section class="editor-shell">
<button
class="back-button"
type="button"
@click="navigateBackToContent"
>
<v-icon :icon="mdiArrowLeft" />
Back to content
</button>
<div
v-if="!isCreateMode && detailStore.isLoading"
class="page-message"
@@ -838,9 +865,22 @@
color: #172033;
}
.back-button,
.primary-button,
.secondary-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
}
.back-button {
@apply w-fit border;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
}
.back-button:hover {
background: #172033;
color: #fffaf2;
}
.primary-button {

View File

@@ -1,17 +1,20 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const today = startOfDay(new Date());
const viewMode = ref('month');
const cursorDate = ref(today);
const viewMode = ref(parseViewMode(route.query.view));
const cursorDate = ref(parseCursorDate(route.query.date, today));
const contentStatusMeta = {
Draft: { tone: 'production' },
@@ -165,7 +168,7 @@
dayKey: dateKey(item.dueDate),
timeLabel: formatHour(item.dueDate),
tone: statusMeta.tone,
route: { name: 'content-item-detail', params: { id: item.id } },
route: { name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } },
};
}
@@ -275,6 +278,45 @@
function sortByDate(left, right) {
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
}
function parseViewMode(value) {
return ['month', 'week', 'upcoming'].includes(value) ? value : 'month';
}
function parseCursorDate(value, fallback) {
if (typeof value !== 'string') {
return fallback;
}
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? fallback : startOfDay(parsed);
}
watch(
() => route.query,
query => {
viewMode.value = parseViewMode(query.view);
cursorDate.value = parseCursorDate(query.date, today);
}
);
watch(
() => [viewMode.value, dateKey(cursorDate.value)],
([view, date]) => {
if (route.query.view === view && route.query.date === date) {
return;
}
router.replace({
name: 'content-items',
query: {
...route.query,
view,
date,
},
});
}
);
</script>
<template>
@@ -429,7 +471,7 @@
<router-link
v-for="item in upcomingItems"
:key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id } }"
:to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
class="item-card"
>
<div class="version-chip">{{ item.currentRevisionLabel }}</div>

View File

@@ -11,6 +11,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const workspaces = ref([]);
const activeWorkspaceId = ref(null);
const visibleWorkspaceIds = ref([]);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
@@ -25,11 +26,43 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const activeWorkspace = computed(() =>
workspaces.value.find(workspace => workspace.id === activeWorkspaceId.value) ?? null
);
const isAllWorkspacesSelected = computed(() =>
activeWorkspaceId.value === null && workspaces.value.length > 1
);
const visibleWorkspaceCount = computed(() =>
activeWorkspaceId.value ? 1 : visibleWorkspaceIds.value.length
);
const areAllWorkspacesVisible = computed(() =>
activeWorkspaceId.value === null &&
workspaces.value.length > 1 &&
visibleWorkspaceIds.value.length === workspaces.value.length
);
const visibleWorkspaceIdSet = computed(() => new Set(visibleWorkspaceIds.value));
const workspaceScopeKey = computed(() =>
activeWorkspaceId.value ?? visibleWorkspaceIds.value.slice().sort().join(',')
);
function allWorkspaceIds() {
return workspaces.value.map(workspace => workspace.id);
}
function normalizeVisibleWorkspaces() {
const workspaceIds = allWorkspaceIds();
const knownWorkspaceIds = new Set(workspaceIds);
const nextVisibleWorkspaceIds = visibleWorkspaceIds.value.filter(workspaceId =>
knownWorkspaceIds.has(workspaceId)
);
visibleWorkspaceIds.value = nextVisibleWorkspaceIds.length > 0
? nextVisibleWorkspaceIds
: workspaceIds;
}
async function fetchWorkspaces() {
if (!authStore.isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
visibleWorkspaceIds.value = [];
error.value = null;
return;
}
@@ -40,9 +73,10 @@ export const useWorkspaceStore = defineStore('workspace', () => {
try {
const response = await client.get('/api/workspaces');
workspaces.value = response.data ?? [];
normalizeVisibleWorkspaces();
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
activeWorkspaceId.value = workspaces.value.length > 1 ? null : workspaces.value[0]?.id ?? null;
}
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
@@ -75,6 +109,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
workspaces.value = [...workspaces.value, response.data]
.sort((left, right) => left.name.localeCompare(right.name));
activeWorkspaceId.value = response.data.id;
visibleWorkspaceIds.value = [response.data.id];
try {
await client.post('/api/clients', {
@@ -172,10 +207,62 @@ export const useWorkspaceStore = defineStore('workspace', () => {
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
visibleWorkspaceIds.value = [workspaceId];
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
}
}
function setAllWorkspaces() {
if (workspaces.value.length > 1) {
activeWorkspaceId.value = null;
visibleWorkspaceIds.value = allWorkspaceIds();
}
}
function isWorkspaceVisible(workspaceId) {
if (activeWorkspaceId.value) {
return workspaceId === activeWorkspaceId.value;
}
if (visibleWorkspaceIds.value.length === 0) {
return true;
}
return visibleWorkspaceIdSet.value.has(workspaceId);
}
function toggleWorkspaceVisibility(workspaceId) {
if (!workspaces.value.some(workspace => workspace.id === workspaceId)) {
return;
}
const wasFocusedOnSingleWorkspace = Boolean(activeWorkspaceId.value);
activeWorkspaceId.value = null;
const visibleIds = new Set(
wasFocusedOnSingleWorkspace || visibleWorkspaceIds.value.length === 0
? allWorkspaceIds()
: visibleWorkspaceIds.value
);
if (visibleIds.has(workspaceId)) {
visibleIds.delete(workspaceId);
} else {
visibleIds.add(workspaceId);
}
if (visibleIds.size === 0) {
visibleIds.add(workspaceId);
}
const nextVisibleWorkspaceIds = allWorkspaceIds().filter(id => visibleIds.has(id));
if (nextVisibleWorkspaceIds.length === 1) {
setActiveWorkspace(nextVisibleWorkspaceIds[0]);
return;
}
visibleWorkspaceIds.value = nextVisibleWorkspaceIds;
}
async function fetchInvites(workspaceId = activeWorkspaceId.value) {
if (!authStore.isAuthenticated || !workspaceId) {
invitesByWorkspace.value = {};
@@ -257,6 +344,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
if (!isAuthenticated) {
workspaces.value = [];
activeWorkspaceId.value = null;
visibleWorkspaceIds.value = [];
error.value = null;
return;
}
@@ -270,6 +358,11 @@ export const useWorkspaceStore = defineStore('workspace', () => {
workspaces,
activeWorkspaceId,
activeWorkspace,
visibleWorkspaceIds,
isAllWorkspacesSelected,
visibleWorkspaceCount,
areAllWorkspacesVisible,
workspaceScopeKey,
isLoading,
isCreating,
isUpdating,
@@ -288,5 +381,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
fetchMembers,
inviteMember,
setActiveWorkspace,
setAllWorkspaces,
isWorkspaceVisible,
toggleWorkspaceVisibility,
};
});

View File

@@ -254,9 +254,21 @@
to="/"
>
<span class="brand-mark">S</span>
<div v-if="isExpanded">
<div class="brand-name">Socialize</div>
<div class="brand-caption">{{ t('nav.brandCaption') }}</div>
<div
v-if="isExpanded"
class="brand-copy"
>
<div class="brand-heading">
<span class="brand-name-wrap">
<span class="brand-name">Socialize</span>
<span
class="brand-stage-badge"
:aria-label="t('nav.brandStageLabel')"
>
<span>{{ t('nav.brandStage') }}</span>
</span>
</span>
</div>
</div>
</router-link>
</div>
@@ -635,7 +647,7 @@
}
.brand-link {
@apply flex min-w-0 items-center gap-3 no-underline;
@apply flex min-w-0 items-start gap-3 no-underline;
color: inherit;
}
@@ -643,20 +655,38 @@
@apply w-full justify-center;
}
.brand-copy {
@apply min-w-0;
}
.brand-heading {
@apply flex min-w-0 items-center;
}
.brand-mark {
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1.1rem] text-xl font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.brand-name {
@apply text-lg font-black uppercase tracking-[0.18em];
color: #172033;
.brand-name-wrap {
@apply relative inline-flex min-w-0 items-center;
}
.brand-caption {
@apply text-xs uppercase tracking-[0.24em];
color: #5d6b82;
.brand-name {
@apply min-w-0 text-lg font-black uppercase tracking-[0.18em];
color: #172033;
line-height: 2.75rem;
}
.brand-stage-badge {
@apply absolute inline-flex h-4 items-center rounded-sm border px-1.5 text-[0.55rem] font-black uppercase tracking-[0.08em];
background: rgba(255, 231, 199, 0.46);
border-color: rgba(242, 179, 107, 0.38);
color: #925000;
left: 0;
line-height: 1;
top: calc(100% - 0.45rem);
}
.side-menu {

View File

@@ -12,6 +12,8 @@
import {
mdiChevronDown,
mdiCogOutline,
mdiEyeOffOutline,
mdiEyeOutline,
mdiPlus,
mdiSwapHorizontal,
} from '@mdi/js';
@@ -37,6 +39,7 @@
);
});
const canSwitchWorkspaces = computed(() => visibleWorkspaces.value.length > 1);
const canSelectAllWorkspaces = computed(() => visibleWorkspaces.value.length > 1);
const canSwitchOrganizations = computed(() => organizationStore.organizations.length > 1);
const switchableOrganizations = computed(() =>
organizationStore.organizations.filter(
@@ -51,8 +54,19 @@
const canOpenWorkspaceMenu = computed(() =>
canSwitchWorkspaces.value || canSwitchOrganizations.value || canManageWorkspaces.value || Boolean(activeOrganization.value)
);
const activeWorkspaceName = computed(() =>
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
const activeWorkspaceName = computed(() => {
if (workspaceStore.areAllWorkspacesVisible) {
return t('workspaceSelector.allWorkspaces');
}
if (workspaceStore.isAllWorkspacesSelected) {
return t('workspaceSelector.multipleWorkspaces');
}
return workspaceStore.activeWorkspace?.name || t('nav.noWorkspace');
});
const activeWorkspaceLogoUrl = computed(() =>
workspaceStore.isAllWorkspacesSelected ? null : workspaceStore.activeWorkspace?.logoUrl
);
const activeOrganizationName = computed(() =>
activeOrganization.value?.name || t('workspaceSelector.noOrganization')
@@ -73,13 +87,30 @@
isOrganizationListOpen.value = false;
}
function chooseAllWorkspaces() {
workspaceStore.setAllWorkspaces();
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
}
function toggleWorkspaceVisibility(workspaceId) {
workspaceStore.toggleWorkspaceVisibility(workspaceId);
}
function chooseOrganization(organizationId) {
organizationStore.setSelectedOrganization(organizationId);
const nextWorkspace = workspaceStore.workspaces.find(
workspace => workspace.organizationId === organizationId
);
workspaceStore.setActiveWorkspace(nextWorkspace?.id ?? null);
const organizationWorkspaceCount = workspaceStore.workspaces.filter(
workspace => workspace.organizationId === organizationId
).length;
if (organizationWorkspaceCount > 1) {
workspaceStore.setAllWorkspaces();
} else {
workspaceStore.setActiveWorkspace(nextWorkspace?.id ?? null);
}
isOrganizationListOpen.value = false;
}
@@ -137,7 +168,7 @@
>
<AppAvatar
:name="activeWorkspaceName"
:src="workspaceStore.activeWorkspace?.logoUrl"
:src="activeWorkspaceLogoUrl"
size="sm"
/>
<span class="label workspace-trigger-label">{{ activeWorkspaceName }}</span>
@@ -153,11 +184,31 @@
v-if="isWorkspaceMenuOpen"
class="user-menu"
>
<button
v-if="canSelectAllWorkspaces"
class="user-menu-item all-workspaces-item"
:class="{ 'user-menu-item-active': workspaceStore.isAllWorkspacesSelected }"
type="button"
@click="chooseAllWorkspaces"
>
<AppAvatar
:name="t('workspaceSelector.allWorkspaces')"
size="sm"
/>
<span class="user-menu-item-copy">
<span>{{ t('workspaceSelector.allWorkspaces') }}</span>
<small>{{ t('workspaceSelector.allWorkspacesDescription') }}</small>
</span>
</button>
<div
v-for="workspace in visibleWorkspaces"
:key="workspace.id"
class="workspace-menu-row"
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
:class="{
'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId,
'workspace-menu-row-muted': workspaceStore.isAllWorkspacesSelected && !workspaceStore.isWorkspaceVisible(workspace.id),
}"
>
<button
class="user-menu-item workspace-menu-select"
@@ -175,6 +226,16 @@
</span>
</button>
<button
v-if="canSelectAllWorkspaces"
class="workspace-visibility-button"
type="button"
:aria-label="workspaceStore.isWorkspaceVisible(workspace.id) ? t('workspaceSelector.hideWorkspace') : t('workspaceSelector.showWorkspace')"
@click.stop="toggleWorkspaceVisibility(workspace.id)"
>
<v-icon :icon="workspaceStore.isWorkspaceVisible(workspace.id) ? mdiEyeOutline : mdiEyeOffOutline" />
</button>
<button
v-if="canManageWorkspaces"
class="workspace-settings-button"
@@ -326,6 +387,16 @@
color: #172033;
}
.workspace-menu-row-muted {
opacity: 0.58;
}
.all-workspaces-item {
@apply mb-1 border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(23, 32, 51, 0.03);
}
.workspace-menu-select {
@apply min-w-0 flex-1;
}
@@ -339,11 +410,18 @@
color: #526178;
}
.workspace-visibility-button {
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors;
color: #526178;
}
.workspace-visibility-button:hover,
.workspace-settings-button:hover {
background: rgba(23, 32, 51, 0.1);
color: #172033;
}
.workspace-visibility-button :deep(.v-icon),
.workspace-settings-button :deep(.v-icon) {
font-size: 1rem;
}

View File

@@ -360,10 +360,15 @@
"saving": "Saving..."
},
"workspaceSelector": {
"allWorkspaces": "All Workspaces",
"allWorkspacesDescription": "Show every workspace",
"createAction": "Add workspace",
"hideWorkspace": "Hide workspace",
"multipleWorkspaces": "Multiple Workspaces",
"organizationLabel": "Organization",
"organizationSettings": "Organization settings",
"noOrganization": "No organization",
"showWorkspace": "Show workspace",
"workspaceSettings": "Workspace settings"
},
"workspaceCreate": {
@@ -443,6 +448,8 @@
}
},
"nav": {
"brandStage": "Alpha Preview",
"brandStageLabel": "Product stage: Alpha Preview",
"brandCaption": "Approval workflow",
"workspace": "Workspace",
"notifications": "Notifications",
@@ -826,7 +833,6 @@
"selectCampaign": "Select a campaign",
"dueDate": "Due date",
"publicationTargets": "Publication targets",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Publication message",
"hashtags": "Hashtags",
"hashtagsPlaceholder": "#launch #brand #campaign",

View File

@@ -360,10 +360,15 @@
"saving": "Enregistrement..."
},
"workspaceSelector": {
"allWorkspaces": "Tous les espaces",
"allWorkspacesDescription": "Afficher tous les espaces",
"createAction": "Ajouter un espace",
"hideWorkspace": "Masquer l'espace",
"multipleWorkspaces": "Plusieurs espaces",
"organizationLabel": "Organisation",
"organizationSettings": "Parametres de l'organisation",
"noOrganization": "Aucune organisation",
"showWorkspace": "Afficher l'espace",
"workspaceSettings": "Parametres de l'espace"
},
"workspaceCreate": {
@@ -443,6 +448,8 @@
}
},
"nav": {
"brandStage": "Aperçu alpha",
"brandStageLabel": "Statut du produit : aperçu alpha",
"brandCaption": "Flux d'approbation",
"workspace": "Espace de travail",
"notifications": "Notifications",
@@ -826,7 +833,6 @@
"selectCampaign": "Sélectionner une campagne",
"dueDate": "Date d'échéance",
"publicationTargets": "Cibles de publication",
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
"publicationMessage": "Message de publication",
"hashtags": "Hashtags",
"hashtagsPlaceholder": "#lancement #marque #campagne",

View File

@@ -6,7 +6,17 @@
to="/"
>
<span class="site-brand-mark">S</span>
<span class="site-brand-text">Socialize</span>
<span class="site-brand-heading">
<span class="site-brand-text-wrap">
<span class="site-brand-text">Socialize</span>
<span
class="site-brand-stage-badge"
:aria-label="t('nav.brandStageLabel')"
>
<span>{{ t('nav.brandStage') }}</span>
</span>
</span>
</span>
</router-link>
<nav
@@ -262,7 +272,7 @@
}
.site-brand {
@apply flex min-w-0 items-center gap-3 no-underline;
@apply flex min-w-0 items-start gap-3 no-underline;
color: #172033;
}
@@ -272,8 +282,27 @@
color: #fffaf2;
}
.site-brand-heading {
@apply flex min-w-0 items-center;
}
.site-brand-text-wrap {
@apply relative inline-flex min-w-0 items-center;
}
.site-brand-text {
@apply truncate text-lg font-black uppercase tracking-[0.18em];
line-height: 2.5rem;
}
.site-brand-stage-badge {
@apply absolute inline-flex h-4 items-center rounded-sm border px-1.5 text-[0.55rem] font-black uppercase tracking-[0.08em];
background: rgba(255, 231, 199, 0.46);
border-color: rgba(242, 179, 107, 0.38);
color: #925000;
left: 0;
line-height: 1;
top: calc(100% - 0.45rem);
}
.site-nav {
@@ -390,7 +419,7 @@
}
@media (max-width: 420px) {
.site-brand-text {
.site-brand-heading {
@apply hidden;
}

12
scripts/recycle-database.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
CONTAINER="socialize-postgres"
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
docker stop "$CONTAINER"
docker rm "$CONTAINER"
fi
./scripts/start-infrastructure.sh
./scripts/dev-backend.sh

View File

@@ -2539,6 +2539,99 @@
]
}
},
"/api/channels": {
"post": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersCreateChannelHandler",
"requestBody": {
"x-name": "CreateChannelRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersCreateChannelRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
},
"get": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersGetChannelsHandler",
"parameters": [
{
"name": "workspaceId",
"in": "query",
"schema": {
"type": "string",
"format": "guid",
"nullable": true
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersChannelDto"
}
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/campaigns": {
"post": {
"tags": [
@@ -4612,6 +4705,82 @@
}
}
},
"SocializeApiModulesChannelsHandlersChannelDto": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"format": "guid"
},
"workspaceId": {
"type": "string",
"format": "guid"
},
"name": {
"type": "string"
},
"network": {
"type": "string"
},
"handle": {
"type": "string",
"nullable": true
},
"externalUrl": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string",
"format": "date-time"
}
}
},
"SocializeApiModulesChannelsHandlersCreateChannelRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"workspaceId",
"name",
"network"
],
"properties": {
"workspaceId": {
"type": "string",
"format": "guid",
"minLength": 1,
"nullable": false
},
"name": {
"type": "string",
"maxLength": 256,
"minLength": 0,
"nullable": false
},
"network": {
"type": "string",
"minLength": 1,
"nullable": false
},
"handle": {
"type": "string",
"maxLength": 256,
"minLength": 0,
"nullable": true
},
"externalUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
}
}
},
"SocializeApiModulesChannelsHandlersGetChannelsRequest": {
"type": "object",
"additionalProperties": false
},
"SocializeApiModulesCampaignsHandlersCampaignDto": {
"type": "object",
"additionalProperties": false,