Add real workspace channels
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Socialize.Api.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
using Socialize.Api.Modules.Assets.Data;
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
|
using Socialize.Api.Modules.Channels.Data;
|
||||||
using Socialize.Api.Modules.Clients.Data;
|
using Socialize.Api.Modules.Clients.Data;
|
||||||
using Socialize.Api.Modules.Comments.Data;
|
using Socialize.Api.Modules.Comments.Data;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
@@ -22,6 +23,7 @@ public class AppDbContext(
|
|||||||
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
|
||||||
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
public DbSet<Workspace> Workspaces => Set<Workspace>();
|
||||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||||
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<Client> Clients => Set<Client>();
|
public DbSet<Client> Clients => Set<Client>();
|
||||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||||
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
|
public DbSet<ContentItem> ContentItems => Set<ContentItem>();
|
||||||
@@ -46,6 +48,7 @@ public class AppDbContext(
|
|||||||
|
|
||||||
builder.ConfigureOrganizationsModule();
|
builder.ConfigureOrganizationsModule();
|
||||||
builder.ConfigureWorkspacesModule();
|
builder.ConfigureWorkspacesModule();
|
||||||
|
builder.ConfigureChannelsModule();
|
||||||
builder.ConfigureClientsModule();
|
builder.ConfigureClientsModule();
|
||||||
builder.ConfigureCampaignsModule();
|
builder.ConfigureCampaignsModule();
|
||||||
builder.ConfigureContentItemsModule();
|
builder.ConfigureContentItemsModule();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Socialize.Api.Modules.Identity.Contracts;
|
|||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Modules.Assets.Data;
|
using Socialize.Api.Modules.Assets.Data;
|
||||||
using Socialize.Api.Modules.Approvals.Data;
|
using Socialize.Api.Modules.Approvals.Data;
|
||||||
|
using Socialize.Api.Modules.Channels.Data;
|
||||||
using Socialize.Api.Modules.Comments.Data;
|
using Socialize.Api.Modules.Comments.Data;
|
||||||
using Socialize.Api.Modules.ContentItems.Data;
|
using Socialize.Api.Modules.ContentItems.Data;
|
||||||
using Socialize.Api.Modules.Clients.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 OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
|
||||||
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
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 ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
|
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 ScopedCampaignId = Guid.Parse("33333333-3333-3333-3333-333333333333");
|
||||||
private static readonly Guid HiddenCampaignId = Guid.Parse("33333333-3333-3333-3333-444444444444");
|
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 ScopedContentItemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
|
||||||
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
|
private static readonly Guid HiddenContentItemId = Guid.Parse("44444444-4444-4444-4444-555555555555");
|
||||||
private static readonly Guid ScopedAssetId = Guid.Parse("55555555-5555-5555-5555-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);
|
dbContext.Organizations.Add(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
organization.Name = "Northstar Collective";
|
organization.Name = "Northstar Agency";
|
||||||
organization.OwnerUserId = managerUserId;
|
organization.OwnerUserId = managerUserId;
|
||||||
|
|
||||||
await UpsertOrganizationMembershipAsync(
|
await UpsertOrganizationMembershipAsync(
|
||||||
@@ -309,32 +314,31 @@ public static class DevelopmentSeedExtensions
|
|||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Workspace? workspace = await dbContext.Workspaces
|
await UpsertWorkspaceAsync(
|
||||||
.SingleOrDefaultAsync(candidate => candidate.Id == WorkspaceId, cancellationToken);
|
dbContext,
|
||||||
if (workspace is null)
|
WorkspaceId,
|
||||||
{
|
OrganizationId,
|
||||||
workspace = new Workspace
|
managerUserId,
|
||||||
{
|
"Luma Coffee",
|
||||||
Id = WorkspaceId,
|
"America/Montreal",
|
||||||
Name = string.Empty,
|
"/images/seed/luma-coffee-logo.svg",
|
||||||
TimeZone = string.Empty,
|
cancellationToken);
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
await UpsertWorkspaceAsync(
|
||||||
};
|
dbContext,
|
||||||
dbContext.Workspaces.Add(workspace);
|
AtlasWorkspaceId,
|
||||||
}
|
OrganizationId,
|
||||||
|
managerUserId,
|
||||||
workspace.Name = "Northstar Studio";
|
"Atlas Bakery",
|
||||||
workspace.OrganizationId = OrganizationId;
|
"America/Montreal",
|
||||||
workspace.OwnerUserId = managerUserId;
|
"/images/seed/atlas-bakery-logo.svg",
|
||||||
workspace.TimeZone = "America/Montreal";
|
cancellationToken);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
await UpsertClientAsync(
|
await UpsertClientAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
ScopedClientId,
|
ScopedClientId,
|
||||||
"Luma Coffee",
|
"Luma Coffee",
|
||||||
"Active",
|
"Active",
|
||||||
"https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=200&q=80",
|
"/images/seed/luma-coffee-logo.svg",
|
||||||
"Sofia Martin",
|
"Sofia Martin",
|
||||||
"client@socialize.local",
|
"client@socialize.local",
|
||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
@@ -344,10 +348,10 @@ public static class DevelopmentSeedExtensions
|
|||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
"Atlas Bakery",
|
"Atlas Bakery",
|
||||||
"Active",
|
"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 Cole",
|
||||||
"nina@atlasbakery.test",
|
"nina@atlasbakery.test",
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
await UpsertCampaignAsync(
|
await UpsertCampaignAsync(
|
||||||
@@ -365,7 +369,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
await UpsertCampaignAsync(
|
await UpsertCampaignAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
HiddenCampaignId,
|
HiddenCampaignId,
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
"Summer Retention",
|
"Summer Retention",
|
||||||
"Planned",
|
"Planned",
|
||||||
@@ -375,6 +379,34 @@ public static class DevelopmentSeedExtensions
|
|||||||
"Sequence email and paid social updates together.",
|
"Sequence email and paid social updates together.",
|
||||||
cancellationToken);
|
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(
|
await UpsertContentItemAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
ScopedContentItemId,
|
ScopedContentItemId,
|
||||||
@@ -383,7 +415,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
ScopedCampaignId,
|
ScopedCampaignId,
|
||||||
"Spring launch hero video",
|
"Spring launch hero video",
|
||||||
"Fresh seasonal menu launch across Instagram and TikTok.",
|
"Fresh seasonal menu launch across Instagram and TikTok.",
|
||||||
"Instagram Reel, TikTok",
|
"Luma Coffee Instagram, Luma Coffee TikTok",
|
||||||
"In approval",
|
"In approval",
|
||||||
DateTimeOffset.UtcNow.AddDays(3),
|
DateTimeOffset.UtcNow.AddDays(3),
|
||||||
"v3",
|
"v3",
|
||||||
@@ -392,22 +424,22 @@ public static class DevelopmentSeedExtensions
|
|||||||
await UpsertContentItemAsync(
|
await UpsertContentItemAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
HiddenContentItemId,
|
HiddenContentItemId,
|
||||||
WorkspaceId,
|
AtlasWorkspaceId,
|
||||||
HiddenClientId,
|
HiddenClientId,
|
||||||
HiddenCampaignId,
|
HiddenCampaignId,
|
||||||
"Bakery loyalty carousel",
|
"Bakery loyalty carousel",
|
||||||
"Reward regular customers with a four-card retention carousel.",
|
"Reward regular customers with a four-card retention carousel.",
|
||||||
"Instagram Carousel",
|
"Atlas Bakery Instagram",
|
||||||
"Draft",
|
"Draft",
|
||||||
DateTimeOffset.UtcNow.AddDays(10),
|
DateTimeOffset.UtcNow.AddDays(10),
|
||||||
"v1",
|
"v1",
|
||||||
1,
|
1,
|
||||||
cancellationToken);
|
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-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.", "Instagram Reel, TikTok", "Addressed internal pacing feedback.", providerUserId, DateTimeOffset.UtcNow.AddDays(-3), 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.", "Instagram Reel, TikTok", "Client-facing draft after copy cleanup.", providerUserId, DateTimeOffset.UtcNow.AddDays(-1), 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.", "Instagram Carousel", "First draft.", managerUserId, DateTimeOffset.UtcNow.AddDays(-2), 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);
|
Asset? asset = await dbContext.Assets.SingleOrDefaultAsync(candidate => candidate.Id == ScopedAssetId, cancellationToken);
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
@@ -535,6 +567,38 @@ public static class DevelopmentSeedExtensions
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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(
|
private static async Task UpsertClientAsync(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
Guid id,
|
Guid id,
|
||||||
@@ -604,6 +668,37 @@ public static class DevelopmentSeedExtensions
|
|||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
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(
|
private static async Task UpsertContentItemAsync(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
1540
backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs
generated
Normal file
1540
backend/src/Socialize.Api/Migrations/20260505162446_AddChannels.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -491,6 +491,48 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("Campaigns", (string)null);
|
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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Clients.Data.Client", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
12
backend/src/Socialize.Api/Modules/Channels/Data/Channel.cs
Normal file
12
backend/src/Socialize.Api/Modules/Channels/Data/Channel.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Socialize.Api.Modules.Channels;
|
||||||
|
|
||||||
|
public static class DependencyInjection
|
||||||
|
{
|
||||||
|
public static WebApplicationBuilder AddChannelsModule(
|
||||||
|
this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using Socialize.Api.Infrastructure;
|
|||||||
using Socialize.Api.Infrastructure.Development;
|
using Socialize.Api.Infrastructure.Development;
|
||||||
using Socialize.Api.Modules.Approvals;
|
using Socialize.Api.Modules.Approvals;
|
||||||
using Socialize.Api.Modules.Assets;
|
using Socialize.Api.Modules.Assets;
|
||||||
|
using Socialize.Api.Modules.Channels;
|
||||||
using Socialize.Api.Modules.Clients;
|
using Socialize.Api.Modules.Clients;
|
||||||
using Socialize.Api.Modules.Comments;
|
using Socialize.Api.Modules.Comments;
|
||||||
using Socialize.Api.Modules.ContentItems;
|
using Socialize.Api.Modules.ContentItems;
|
||||||
@@ -65,6 +66,7 @@ builder.AddInfrastructureModule();
|
|||||||
builder.AddIdentityModule();
|
builder.AddIdentityModule();
|
||||||
builder.AddOrganizationsModule();
|
builder.AddOrganizationsModule();
|
||||||
builder.AddWorkspaceModule();
|
builder.AddWorkspaceModule();
|
||||||
|
builder.AddChannelsModule();
|
||||||
builder.AddClientsModule();
|
builder.AddClientsModule();
|
||||||
builder.AddCampaignsModule();
|
builder.AddCampaignsModule();
|
||||||
builder.AddContentItemsModule();
|
builder.AddContentItemsModule();
|
||||||
|
|||||||
43
docs/FEATURES/channels.md
Normal file
43
docs/FEATURES/channels.md
Normal 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.
|
||||||
23
docs/TASKS/content/002-content-detail-back-navigation.md
Normal file
23
docs/TASKS/content/002-content-detail-back-navigation.md
Normal 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
|
||||||
|
```
|
||||||
34
docs/TASKS/content/003-real-workspace-channels.md
Normal file
34
docs/TASKS/content/003-real-workspace-channels.md
Normal 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
|
||||||
|
```
|
||||||
11
frontend/public/images/seed/atlas-bakery-logo.svg
Normal file
11
frontend/public/images/seed/atlas-bakery-logo.svg
Normal 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 |
10
frontend/public/images/seed/luma-coffee-logo.svg
Normal file
10
frontend/public/images/seed/luma-coffee-logo.svg
Normal 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 |
9
frontend/public/images/seed/northstar-agency-logo.svg
Normal file
9
frontend/public/images/seed/northstar-agency-logo.svg
Normal 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 |
106
frontend/src/api/schema.d.ts
vendored
106
frontend/src/api/schema.d.ts
vendored
@@ -788,6 +788,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/campaigns": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1411,6 +1427,27 @@ export interface components {
|
|||||||
primaryContactEmail?: string | null;
|
primaryContactEmail?: string | null;
|
||||||
primaryContactPortraitUrl?: 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: {
|
SocializeApiModulesCampaignsHandlersCampaignDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
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: {
|
SocializeApiModulesCampaignsHandlersGetCampaignsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -1,52 +1,19 @@
|
|||||||
import { computed } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
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 { 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', () => {
|
export const useChannelsStore = defineStore('channels', () => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const contentItemsStore = useContentItemsStore();
|
const client = useClient();
|
||||||
const customChannelsByWorkspace = useSessionStorage('workspace-custom-channels', {}, {
|
|
||||||
serializer: {
|
|
||||||
read: value => (value ? JSON.parse(value) : {}),
|
|
||||||
write: value => JSON.stringify(value ?? {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const channels = computed(() => {
|
const channels = ref([]);
|
||||||
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
|
const isLoading = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
if (!currentWorkspaceId) {
|
const error = ref(null);
|
||||||
return [];
|
const loadedWorkspaceId = ref(null);
|
||||||
}
|
|
||||||
|
|
||||||
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 availableNetworks = [
|
const availableNetworks = [
|
||||||
'Instagram',
|
'Instagram',
|
||||||
@@ -59,64 +26,102 @@ export const useChannelsStore = defineStore('channels', () => {
|
|||||||
'Website',
|
'Website',
|
||||||
];
|
];
|
||||||
|
|
||||||
function createChannel(payload) {
|
async function fetchChannels({ force = false } = {}) {
|
||||||
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
|
const currentWorkspaceId = workspaceStore.activeWorkspaceId;
|
||||||
|
|
||||||
if (!currentWorkspaceId) {
|
if (!authStore.isAuthenticated || !currentWorkspaceId) {
|
||||||
|
channels.value = [];
|
||||||
|
error.value = null;
|
||||||
|
loadedWorkspaceId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && loadedWorkspaceId.value === currentWorkspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.get('/api/channels', {
|
||||||
|
params: {
|
||||||
|
workspaceId: currentWorkspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
channels.value = response.data ?? [];
|
||||||
|
loadedWorkspaceId.value = currentWorkspaceId;
|
||||||
|
} 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 (!authStore.isAuthenticated || !currentWorkspaceId) {
|
||||||
throw new Error('An active workspace is required to create a channel.');
|
throw new Error('An active workspace is required to create a channel.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedName = payload.name.trim();
|
if (isCreating.value) {
|
||||||
const normalizedNetwork = payload.network.trim();
|
throw new Error('A channel creation request is already in progress.');
|
||||||
|
|
||||||
if (!normalizedName) {
|
|
||||||
throw new Error('Channel name is required.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedNetwork) {
|
isCreating.value = true;
|
||||||
throw new Error('Network is required.');
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.post('/api/channels', {
|
||||||
|
...payload,
|
||||||
|
workspaceId: currentWorkspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
channels.value = [...channels.value, response.data]
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!availableNetworks.includes(normalizedNetwork)) {
|
return response.data;
|
||||||
throw new Error('Selected network is invalid.');
|
} 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = channels.value.some(channel =>
|
watch(
|
||||||
channel.name.toLowerCase() === normalizedName.toLowerCase()
|
() => [authStore.isAuthenticated, workspaceStore.activeWorkspaceId],
|
||||||
&& (channel.network ?? '').toLowerCase() === normalizedNetwork.toLowerCase()
|
async ([isAuthenticated, workspaceId]) => {
|
||||||
);
|
if (!isAuthenticated || !workspaceId) {
|
||||||
if (existing) {
|
channels.value = [];
|
||||||
throw new Error('A channel with this name already exists for the selected network.');
|
error.value = null;
|
||||||
|
loadedWorkspaceId.value = null;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = customChannelsByWorkspace.value[currentWorkspaceId] ?? [];
|
await fetchChannels();
|
||||||
customChannelsByWorkspace.value = {
|
|
||||||
...customChannelsByWorkspace.value,
|
|
||||||
[currentWorkspaceId]: [
|
|
||||||
...next,
|
|
||||||
{
|
|
||||||
id: normalizeChannelKey(`${normalizedNetwork}-${normalizedName}`),
|
|
||||||
name: normalizedName,
|
|
||||||
network: normalizedNetwork,
|
|
||||||
},
|
},
|
||||||
],
|
{ immediate: true }
|
||||||
};
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function parseTargets(value) {
|
|
||||||
return (value ?? '')
|
|
||||||
.split(/[,\n]+/)
|
|
||||||
.map(target => target.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeChannelKey(value) {
|
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableNetworks,
|
availableNetworks,
|
||||||
channels,
|
channels,
|
||||||
|
isLoading,
|
||||||
|
isCreating,
|
||||||
|
error,
|
||||||
|
fetchChannels,
|
||||||
createChannel,
|
createChannel,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
@@ -87,11 +87,11 @@
|
|||||||
isCreateFormVisible.value = true;
|
isCreateFormVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitForm() {
|
async function submitForm() {
|
||||||
formError.value = null;
|
formError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
channelsStore.createChannel({
|
await channelsStore.createChannel({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
network: form.network,
|
network: form.network,
|
||||||
});
|
});
|
||||||
@@ -118,6 +118,10 @@
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
channelsStore.fetchChannels();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -178,15 +182,30 @@
|
|||||||
<button
|
<button
|
||||||
class="primary"
|
class="primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="channelsStore.isCreating"
|
||||||
@click="submitForm"
|
@click="submitForm"
|
||||||
>
|
>
|
||||||
{{ t('channels.createTitle') }}
|
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
class="channel-grid"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { mdiArrowLeft } from '@mdi/js';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||||
@@ -332,6 +333,23 @@
|
|||||||
commentForm.body = '';
|
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) {
|
function formatDateTime(value) {
|
||||||
return value ? new Date(value).toLocaleString() : '';
|
return value ? new Date(value).toLocaleString() : '';
|
||||||
}
|
}
|
||||||
@@ -373,6 +391,15 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="editor-shell">
|
<section class="editor-shell">
|
||||||
|
<button
|
||||||
|
class="back-button"
|
||||||
|
type="button"
|
||||||
|
@click="navigateBackToContent"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiArrowLeft" />
|
||||||
|
Back to content
|
||||||
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!isCreateMode && detailStore.isLoading"
|
v-if="!isCreateMode && detailStore.isLoading"
|
||||||
class="page-message"
|
class="page-message"
|
||||||
@@ -838,9 +865,22 @@
|
|||||||
color: #172033;
|
color: #172033;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-button,
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-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 {
|
.primary-button {
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const campaignsStore = useCampaignsStore();
|
const campaignsStore = useCampaignsStore();
|
||||||
const contentItemsStore = useContentItemsStore();
|
const contentItemsStore = useContentItemsStore();
|
||||||
|
|
||||||
const today = startOfDay(new Date());
|
const today = startOfDay(new Date());
|
||||||
const viewMode = ref('month');
|
const viewMode = ref(parseViewMode(route.query.view));
|
||||||
const cursorDate = ref(today);
|
const cursorDate = ref(parseCursorDate(route.query.date, today));
|
||||||
|
|
||||||
const contentStatusMeta = {
|
const contentStatusMeta = {
|
||||||
Draft: { tone: 'production' },
|
Draft: { tone: 'production' },
|
||||||
@@ -165,7 +168,7 @@
|
|||||||
dayKey: dateKey(item.dueDate),
|
dayKey: dateKey(item.dueDate),
|
||||||
timeLabel: formatHour(item.dueDate),
|
timeLabel: formatHour(item.dueDate),
|
||||||
tone: statusMeta.tone,
|
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) {
|
function sortByDate(left, right) {
|
||||||
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -429,7 +471,7 @@
|
|||||||
<router-link
|
<router-link
|
||||||
v-for="item in upcomingItems"
|
v-for="item in upcomingItems"
|
||||||
:key="item.id"
|
: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"
|
class="item-card"
|
||||||
>
|
>
|
||||||
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
|
||||||
|
|||||||
@@ -443,6 +443,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"brandStage": "Preview",
|
||||||
|
"brandStageLabel": "Product stage: Preview",
|
||||||
"brandCaption": "Approval workflow",
|
"brandCaption": "Approval workflow",
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
@@ -826,7 +828,6 @@
|
|||||||
"selectCampaign": "Select a campaign",
|
"selectCampaign": "Select a campaign",
|
||||||
"dueDate": "Due date",
|
"dueDate": "Due date",
|
||||||
"publicationTargets": "Publication targets",
|
"publicationTargets": "Publication targets",
|
||||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
|
||||||
"publicationMessage": "Publication message",
|
"publicationMessage": "Publication message",
|
||||||
"hashtags": "Hashtags",
|
"hashtags": "Hashtags",
|
||||||
"hashtagsPlaceholder": "#launch #brand #campaign",
|
"hashtagsPlaceholder": "#launch #brand #campaign",
|
||||||
|
|||||||
@@ -443,6 +443,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"brandStage": "Preview",
|
||||||
|
"brandStageLabel": "Statut du produit : Preview",
|
||||||
"brandCaption": "Flux d'approbation",
|
"brandCaption": "Flux d'approbation",
|
||||||
"workspace": "Espace de travail",
|
"workspace": "Espace de travail",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
@@ -826,7 +828,6 @@
|
|||||||
"selectCampaign": "Sélectionner une campagne",
|
"selectCampaign": "Sélectionner une campagne",
|
||||||
"dueDate": "Date d'échéance",
|
"dueDate": "Date d'échéance",
|
||||||
"publicationTargets": "Cibles de publication",
|
"publicationTargets": "Cibles de publication",
|
||||||
"publicationTargetsPlaceholder": "Instagram Reel, TikTok",
|
|
||||||
"publicationMessage": "Message de publication",
|
"publicationMessage": "Message de publication",
|
||||||
"hashtags": "Hashtags",
|
"hashtags": "Hashtags",
|
||||||
"hashtagsPlaceholder": "#lancement #marque #campagne",
|
"hashtagsPlaceholder": "#lancement #marque #campagne",
|
||||||
|
|||||||
@@ -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": {
|
"/api/campaigns": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"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": {
|
"SocializeApiModulesCampaignsHandlersCampaignDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user