feat: add editable channel images
All checks were successful
deploy-socialize / image (push) Successful in 1m20s
deploy-socialize / deploy (push) Successful in 20s

This commit is contained in:
2026-05-09 13:14:11 -04:00
parent 831ffde411
commit afcdd1ace1
18 changed files with 3786 additions and 16 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddChannelImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "BannerUrl",
table: "Channels",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PortraitUrl",
table: "Channels",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "BannerUrl",
table: "Channels");
migrationBuilder.DropColumn(
name: "PortraitUrl",
table: "Channels");
}
}
}

View File

@@ -817,6 +817,10 @@ namespace Socialize.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("BannerUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -840,6 +844,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)"); .HasColumnType("character varying(64)");
b.Property<string>("PortraitUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("WorkspaceId") b.Property<Guid>("WorkspaceId")
.HasColumnType("uuid"); .HasColumnType("uuid");

View File

@@ -8,5 +8,7 @@ internal class Channel
public required string Network { get; set; } public required string Network { get; set; }
public string? Handle { get; set; } public string? Handle { get; set; }
public string? ExternalUrl { get; set; } public string? ExternalUrl { get; set; }
public string? PortraitUrl { get; set; }
public string? BannerUrl { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

@@ -15,6 +15,8 @@ internal static class ChannelModelConfiguration
channel.Property(x => x.Network).HasMaxLength(64).IsRequired(); channel.Property(x => x.Network).HasMaxLength(64).IsRequired();
channel.Property(x => x.Handle).HasMaxLength(256); channel.Property(x => x.Handle).HasMaxLength(256);
channel.Property(x => x.ExternalUrl).HasMaxLength(2048); channel.Property(x => x.ExternalUrl).HasMaxLength(2048);
channel.Property(x => x.PortraitUrl).HasMaxLength(2048);
channel.Property(x => x.BannerUrl).HasMaxLength(2048);
channel.Property(x => x.CreatedAt) channel.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");

View File

@@ -7,4 +7,6 @@ internal record ChannelDto(
string Network, string Network,
string? Handle, string? Handle,
string? ExternalUrl, string? ExternalUrl,
string? PortraitUrl,
string? BannerUrl,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);

View File

@@ -11,7 +11,9 @@ internal record CreateChannelRequest(
string Name, string Name,
string Network, string Network,
string? Handle, string? Handle,
string? ExternalUrl); string? ExternalUrl,
string? PortraitUrl,
string? BannerUrl);
internal class CreateChannelRequestValidator internal class CreateChannelRequestValidator
: Validator<CreateChannelRequest> : Validator<CreateChannelRequest>
@@ -36,6 +38,8 @@ internal class CreateChannelRequestValidator
.WithMessage("Selected network is invalid."); .WithMessage("Selected network is invalid.");
RuleFor(x => x.Handle).MaximumLength(256); RuleFor(x => x.Handle).MaximumLength(256);
RuleFor(x => x.ExternalUrl).MaximumLength(2048); RuleFor(x => x.ExternalUrl).MaximumLength(2048);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.BannerUrl).MaximumLength(2048);
} }
} }
@@ -72,6 +76,8 @@ internal class CreateChannelHandler(
string normalizedNetwork = request.Network.Trim(); string normalizedNetwork = request.Network.Trim();
string? normalizedHandle = request.Handle?.Trim(); string? normalizedHandle = request.Handle?.Trim();
string? normalizedExternalUrl = request.ExternalUrl?.Trim(); string? normalizedExternalUrl = request.ExternalUrl?.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedBannerUrl = request.BannerUrl?.Trim();
bool duplicateChannel = await dbContext.Channels bool duplicateChannel = await dbContext.Channels
.AnyAsync( .AnyAsync(
@@ -95,6 +101,8 @@ internal class CreateChannelHandler(
Network = normalizedNetwork, Network = normalizedNetwork,
Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle, Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle,
ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl, ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl,
PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl,
BannerUrl = string.IsNullOrWhiteSpace(normalizedBannerUrl) ? null : normalizedBannerUrl,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -108,6 +116,8 @@ internal class CreateChannelHandler(
channel.Network, channel.Network,
channel.Handle, channel.Handle,
channel.ExternalUrl, channel.ExternalUrl,
channel.PortraitUrl,
channel.BannerUrl,
channel.CreatedAt); channel.CreatedAt);
await SendAsync(dto, StatusCodes.Status201Created, ct); await SendAsync(dto, StatusCodes.Status201Created, ct);

View File

@@ -41,6 +41,8 @@ internal class GetChannelsHandler(
channel.Network, channel.Network,
channel.Handle, channel.Handle,
channel.ExternalUrl, channel.ExternalUrl,
channel.PortraitUrl,
channel.BannerUrl,
channel.CreatedAt)) channel.CreatedAt))
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -0,0 +1,120 @@
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;
internal record UpdateChannelRequest(
Guid Id,
Guid WorkspaceId,
string Name,
string Network,
string? Handle,
string? ExternalUrl,
string? PortraitUrl,
string? BannerUrl);
internal class UpdateChannelRequestValidator
: Validator<UpdateChannelRequest>
{
private static readonly string[] AllowedNetworks =
[
"Instagram",
"TikTok",
"Facebook",
"LinkedIn",
"YouTube",
"X",
"Reddit",
"Website",
];
public UpdateChannelRequestValidator()
{
RuleFor(x => x.Id).NotEmpty();
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);
RuleFor(x => x.PortraitUrl).MaximumLength(2048);
RuleFor(x => x.BannerUrl).MaximumLength(2048);
}
}
internal class UpdateChannelHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<UpdateChannelRequest, ChannelDto>
{
public override void Configure()
{
Put("/api/channels/{id}");
Options(o => o.WithTags("Channels"));
}
public override async Task HandleAsync(UpdateChannelRequest request, CancellationToken ct)
{
Channel? channel = await dbContext.Channels
.SingleOrDefaultAsync(candidate => candidate.Id == request.Id && candidate.WorkspaceId == request.WorkspaceId, ct);
if (channel is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedNetwork = request.Network.Trim();
string? normalizedHandle = request.Handle?.Trim();
string? normalizedExternalUrl = request.ExternalUrl?.Trim();
string? normalizedPortraitUrl = request.PortraitUrl?.Trim();
string? normalizedBannerUrl = request.BannerUrl?.Trim();
bool duplicateChannel = await dbContext.Channels
.AnyAsync(
candidate => candidate.Id != channel.Id
&& candidate.WorkspaceId == channel.WorkspaceId
&& candidate.Network == normalizedNetwork
&& candidate.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.Name = normalizedName;
channel.Network = normalizedNetwork;
channel.Handle = string.IsNullOrWhiteSpace(normalizedHandle) ? null : normalizedHandle;
channel.ExternalUrl = string.IsNullOrWhiteSpace(normalizedExternalUrl) ? null : normalizedExternalUrl;
channel.PortraitUrl = string.IsNullOrWhiteSpace(normalizedPortraitUrl) ? null : normalizedPortraitUrl;
channel.BannerUrl = string.IsNullOrWhiteSpace(normalizedBannerUrl) ? null : normalizedBannerUrl;
await dbContext.SaveChangesAsync(ct);
ChannelDto dto = new(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.PortraitUrl,
channel.BannerUrl,
channel.CreatedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,241 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Channels.Data;
namespace Socialize.Api.Modules.Channels.Handlers;
internal record UploadChannelImageRequest(
Guid Id,
IFormFile File);
internal class UploadChannelImageRequestValidator
: Validator<UploadChannelImageRequest>
{
public UploadChannelImageRequestValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.File).NotNull();
}
}
internal class UploadChannelPortraitHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage)
: Endpoint<UploadChannelImageRequest, ChannelDto>
{
public override void Configure()
{
Post("/api/channels/{id}/portrait");
Options(o => o.WithTags("Channels"));
AllowFileUploads();
}
public override async Task HandleAsync(UploadChannelImageRequest request, CancellationToken ct)
{
await UploadImageAsync(request, "portrait", (channel, url) => channel.PortraitUrl = url, ct);
}
private async Task UploadImageAsync(
UploadChannelImageRequest request,
string imageKind,
Action<Channel, string> applyImageUrl,
CancellationToken ct)
{
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == request.Id, ct);
if (channel is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
string? blobUrl = await TryUploadImageAsync(request, channel, imageKind, ct);
if (blobUrl is null)
{
return;
}
applyImageUrl(channel, blobUrl);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(ChannelImageUploadHelpers.ToDto(channel), ct);
}
private async Task<string?> TryUploadImageAsync(
UploadChannelImageRequest request,
Channel channel,
string imageKind,
CancellationToken ct)
{
string normalizedContentType = request.File.ContentType.Trim();
if (request.File.Length <= 0)
{
AddError(request => request.File, "The image must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return null;
}
if (!ChannelImageUploadHelpers.IsSupportedImageContentType(normalizedContentType))
{
AddError(request => request.File, "The image must be a PNG, JPEG, GIF, or WebP file.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return null;
}
string fileName = ChannelImageUploadHelpers.NormalizeFileName(request.File.FileName, normalizedContentType);
string blobName = $"{channel.WorkspaceId}/{SubDirectoryNames.Profile}/channels/{channel.Id}/{imageKind}/{fileName}";
try
{
return await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
blobName,
request.File.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The image file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return null;
}
}
}
internal class UploadChannelBannerHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage)
: Endpoint<UploadChannelImageRequest, ChannelDto>
{
public override void Configure()
{
Post("/api/channels/{id}/banner");
Options(o => o.WithTags("Channels"));
AllowFileUploads();
}
public override async Task HandleAsync(UploadChannelImageRequest request, CancellationToken ct)
{
Channel? channel = await dbContext.Channels.SingleOrDefaultAsync(candidate => candidate.Id == request.Id, ct);
if (channel is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await accessScopeService.CanManageWorkspaceAsync(User, channel.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
string normalizedContentType = request.File.ContentType.Trim();
if (request.File.Length <= 0)
{
AddError(request => request.File, "The image must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!ChannelImageUploadHelpers.IsSupportedImageContentType(normalizedContentType))
{
AddError(request => request.File, "The image must be a PNG, JPEG, GIF, or WebP file.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string fileName = ChannelImageUploadHelpers.NormalizeFileName(request.File.FileName, normalizedContentType);
string blobName = $"{channel.WorkspaceId}/{SubDirectoryNames.Profile}/channels/{channel.Id}/banner/{fileName}";
string blobUrl;
try
{
blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
blobName,
request.File.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The image file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
channel.BannerUrl = blobUrl;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(ChannelImageUploadHelpers.ToDto(channel), ct);
}
}
internal static class ChannelImageUploadHelpers
{
public static ChannelDto ToDto(Channel channel)
{
return new ChannelDto(
channel.Id,
channel.WorkspaceId,
channel.Name,
channel.Network,
channel.Handle,
channel.ExternalUrl,
channel.PortraitUrl,
channel.BannerUrl,
channel.CreatedAt);
}
public static bool IsSupportedImageContentType(string contentType)
{
string normalized = contentType.Trim();
return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/gif", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/webp", StringComparison.OrdinalIgnoreCase);
}
public static string NormalizeFileName(string? fileName, string contentType)
{
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
normalized = $"channel-image{DefaultExtension(contentType)}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
private static string DefaultExtension(string contentType)
{
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return ".png";
}
if (contentType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
{
return ".gif";
}
if (contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase))
{
return ".webp";
}
return ".jpg";
}
}

View File

@@ -14,6 +14,8 @@ A channel has:
- `network` - `network`
- optional `handle` - optional `handle`
- optional `externalUrl` - optional `externalUrl`
- optional `portraitUrl`
- optional `bannerUrl`
- `createdAt` - `createdAt`
`network` is a controlled string matching the frontend channel network options: `network` is a controlled string matching the frontend channel network options:
@@ -33,6 +35,7 @@ Channel names must be unique inside a workspace for the same network.
- Authenticated users with workspace access can list channels for their active workspace. - Authenticated users with workspace access can list channels for their active workspace.
- Workspace managers can create channels. - Workspace managers can create channels.
- Workspace managers can edit channel profile fields and upload portrait/banner images.
- Content planning uses configured channels as selectable destinations. - 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. - Development seed data should create real workspace channels instead of relying on content target labels as fake channels.

View File

@@ -15,6 +15,8 @@ Make channel setup feel like configuring real social destinations and make conte
- Show configured channels as compact, network-shaped preview pages. - Show configured channels as compact, network-shaped preview pages.
- Show a banner/profile preview while creating a channel. - Show a banner/profile preview while creating a channel.
- Use existing channel fields: name, network, handle, and external URL. - Use existing channel fields: name, network, handle, and external URL.
- Let managers edit existing channel fields.
- Let managers upload portrait and banner images for channels.
- In the content creation editor, show a channel setup invitation when the workspace has no configured channels. - In the content creation editor, show a channel setup invitation when the workspace has no configured channels.
## Validation ## Validation
@@ -22,6 +24,7 @@ Make channel setup feel like configuring real social destinations and make conte
```bash ```bash
cd frontend cd frontend
npm run build npm run build
dotnet build backend/Socialize.slnx
``` ```
## Acceptance Criteria ## Acceptance Criteria
@@ -30,3 +33,5 @@ npm run build
- [x] The channels page content area shows preview cards for configured channels. - [x] The channels page content area shows preview cards for configured channels.
- [x] The create-channel form includes a live network-style banner/profile preview. - [x] The create-channel form includes a live network-style banner/profile preview.
- [x] Content creation invites the user to configure channels first when none exist. - [x] Content creation invites the user to configure channels first when none exist.
- [x] Existing channels can be edited after creation.
- [x] Channel portrait and banner images can be uploaded and shown in previews.

View File

@@ -1156,6 +1156,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/channels/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["SocializeApiModulesChannelsHandlersUpdateChannelHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/channels/{id}/portrait": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/channels/{id}/banner": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesChannelsHandlersUploadChannelBannerHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/campaigns": { "/api/campaigns": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2129,6 +2177,8 @@ export interface components {
network?: string; network?: string;
handle?: string | null; handle?: string | null;
externalUrl?: string | null; externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt?: string; createdAt?: string;
}; };
@@ -2139,8 +2189,24 @@ export interface components {
network: string; network: string;
handle?: string | null; handle?: string | null;
externalUrl?: string | null; externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
}; };
SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>; SocializeApiModulesChannelsHandlersGetChannelsRequest: Record<string, never>;
SocializeApiModulesChannelsHandlersUpdateChannelRequest: {
/** Format: guid */
workspaceId: string;
name: string;
network: string;
handle?: string | null;
externalUrl?: string | null;
portraitUrl?: string | null;
bannerUrl?: string | null;
};
SocializeApiModulesChannelsHandlersUploadChannelImageRequest: {
/** Format: binary */
file: string;
};
SocializeApiModulesCampaignsHandlersCampaignDto: { SocializeApiModulesCampaignsHandlersCampaignDto: {
/** Format: guid */ /** Format: guid */
id?: string; id?: string;
@@ -5269,6 +5335,132 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesChannelsHandlersUpdateChannelHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesChannelsHandlersUpdateChannelRequest"];
};
};
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;
};
};
};
SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
};
};
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;
};
};
};
SocializeApiModulesChannelsHandlersUploadChannelBannerHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesChannelsHandlersUploadChannelImageRequest"];
};
};
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?: {

View File

@@ -12,6 +12,7 @@ export const useChannelsStore = defineStore('channels', () => {
const channels = ref([]); const channels = ref([]);
const isLoading = ref(false); const isLoading = ref(false);
const isCreating = ref(false); const isCreating = ref(false);
const isUpdating = ref(false);
const error = ref(null); const error = ref(null);
const loadedWorkspaceId = ref(null); const loadedWorkspaceId = ref(null);
const allWorkspacesKey = '__all__'; const allWorkspacesKey = '__all__';
@@ -104,6 +105,71 @@ export const useChannelsStore = defineStore('channels', () => {
} }
} }
async function updateChannel(channelId, payload) {
if (!authStore.isAuthenticated) {
throw new Error('You must be signed in to update a channel.');
}
if (isUpdating.value) {
throw new Error('A channel update request is already in progress.');
}
isUpdating.value = true;
error.value = null;
try {
const response = await client.put(`/api/channels/${channelId}`, payload);
if (response.data) {
channels.value = channels.value
.map(channel => channel.id === response.data.id ? response.data : channel)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (updateError) {
console.error('Failed to update channel:', updateError);
const message = updateError.response?.data?.errors?.[0]?.reason
?? updateError.response?.data?.message
?? 'Failed to update channel.';
error.value = message;
throw new Error(message);
} finally {
isUpdating.value = false;
}
}
async function uploadChannelImage(channelId, imageKind, file) {
if (!file) {
return null;
}
isUpdating.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append('file', file, file.name || `${imageKind}.jpg`);
const response = await client.post(`/api/channels/${channelId}/${imageKind}`, formData);
if (response.data) {
channels.value = channels.value
.map(channel => channel.id === response.data.id ? response.data : channel)
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (uploadError) {
console.error('Failed to upload channel image:', uploadError);
const message = uploadError.response?.data?.errors?.[0]?.reason
?? uploadError.response?.data?.message
?? 'Failed to upload channel image.';
error.value = message;
throw new Error(message);
} finally {
isUpdating.value = false;
}
}
watch( watch(
() => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey], () => [authStore.isAuthenticated, workspaceStore.workspaceScopeKey],
async ([isAuthenticated]) => { async ([isAuthenticated]) => {
@@ -124,8 +190,11 @@ export const useChannelsStore = defineStore('channels', () => {
channels, channels,
isLoading, isLoading,
isCreating, isCreating,
isUpdating,
error, error,
fetchChannels, fetchChannels,
createChannel, createChannel,
updateChannel,
uploadChannelImage,
}; };
}); });

View File

@@ -8,12 +8,15 @@
import { import {
mdiClose, mdiClose,
mdiFacebook, mdiFacebook,
mdiImage,
mdiInstagram, mdiInstagram,
mdiLinkedin, mdiLinkedin,
mdiMusicNote, mdiMusicNote,
mdiOpenInNew, mdiOpenInNew,
mdiPencil,
mdiPlus, mdiPlus,
mdiReddit, mdiReddit,
mdiContentSave,
mdiWeb, mdiWeb,
mdiYoutube, mdiYoutube,
} from '@mdi/js'; } from '@mdi/js';
@@ -25,6 +28,7 @@
const channelsStore = useChannelsStore(); const channelsStore = useChannelsStore();
const isCreateFormVisible = ref(false); const isCreateFormVisible = ref(false);
const editingChannelId = ref('');
const formError = ref(null); const formError = ref(null);
const activeNetwork = ref('Instagram'); const activeNetwork = ref('Instagram');
const form = reactive({ const form = reactive({
@@ -32,6 +36,8 @@
network: 'Instagram', network: 'Instagram',
handle: '', handle: '',
externalUrl: '', externalUrl: '',
portraitUrl: '',
bannerUrl: '',
}); });
const networkOptions = [ const networkOptions = [
@@ -63,11 +69,16 @@
const channelsForActiveNetwork = computed(() => const channelsForActiveNetwork = computed(() =>
configuredChannels.value.filter(channel => channel.network === activeNetwork.value) configuredChannels.value.filter(channel => channel.network === activeNetwork.value)
); );
const editingChannel = computed(() =>
configuredChannels.value.find(channel => channel.id === editingChannelId.value) ?? null
);
const previewChannel = computed(() => ({ const previewChannel = computed(() => ({
name: form.name.trim() || `${form.network} channel`, name: form.name.trim() || `${form.network} channel`,
network: form.network, network: form.network,
handle: form.handle.trim(), handle: form.handle.trim(),
externalUrl: form.externalUrl.trim(), externalUrl: form.externalUrl.trim(),
portraitUrl: form.portraitUrl.trim(),
bannerUrl: form.bannerUrl.trim(),
workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'), workspaceName: workspaceStore.activeWorkspace?.name ?? t('nav.noWorkspace'),
scheduled: 0, scheduled: 0,
readyCount: 0, readyCount: 0,
@@ -95,6 +106,8 @@
form.network = activeNetwork.value; form.network = activeNetwork.value;
form.handle = ''; form.handle = '';
form.externalUrl = ''; form.externalUrl = '';
form.portraitUrl = '';
form.bannerUrl = '';
formError.value = null; formError.value = null;
} }
@@ -102,6 +115,20 @@
activeNetwork.value = network; activeNetwork.value = network;
resetForm(); resetForm();
form.network = network; form.network = network;
editingChannelId.value = '';
isCreateFormVisible.value = true;
}
function openEditForm(channel) {
activeNetwork.value = channel.network;
editingChannelId.value = channel.id;
form.name = channel.name ?? '';
form.network = channel.network ?? activeNetwork.value;
form.handle = channel.handle ?? '';
form.externalUrl = channel.externalUrl ?? '';
form.portraitUrl = channel.portraitUrl ?? '';
form.bannerUrl = channel.bannerUrl ?? '';
formError.value = null;
isCreateFormVisible.value = true; isCreateFormVisible.value = true;
} }
@@ -117,14 +144,28 @@
formError.value = null; formError.value = null;
try { try {
await channelsStore.createChannel({ const payload = {
name: form.name, name: form.name,
network: form.network, network: form.network,
handle: form.handle, handle: form.handle,
externalUrl: form.externalUrl, externalUrl: form.externalUrl,
}); portraitUrl: form.portraitUrl,
bannerUrl: form.bannerUrl,
};
if (editingChannelId.value) {
await channelsStore.updateChannel(editingChannelId.value, {
id: editingChannelId.value,
workspaceId: editingChannel.value?.workspaceId ?? workspaceStore.activeWorkspaceId,
...payload,
});
} else {
await channelsStore.createChannel(payload);
}
activeNetwork.value = form.network; activeNetwork.value = form.network;
isCreateFormVisible.value = false; isCreateFormVisible.value = false;
editingChannelId.value = '';
resetForm(); resetForm();
} catch (error) { } catch (error) {
formError.value = error.message ?? t('channels.errors.createFailed'); formError.value = error.message ?? t('channels.errors.createFailed');
@@ -164,6 +205,27 @@
return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; return `network-${(network ?? 'other').toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
} }
async function uploadChannelImage(channel, imageKind, event) {
const [file] = Array.from(event.target.files ?? []);
event.target.value = '';
if (!file || !channel) {
return;
}
formError.value = null;
try {
const updated = await channelsStore.uploadChannelImage(channel.id, imageKind, file);
if (editingChannelId.value === channel.id && updated) {
form.portraitUrl = updated.portraitUrl ?? '';
form.bannerUrl = updated.bannerUrl ?? '';
}
} catch (error) {
formError.value = error.message ?? t('channels.errors.updateFailed');
}
}
watch( watch(
() => route.query.create, () => route.query.create,
createValue => { createValue => {
@@ -221,11 +283,25 @@
:class="networkClass(form.network)" :class="networkClass(form.network)"
> >
<div class="channel-banner"> <div class="channel-banner">
<v-icon :icon="networkIcon(form.network)" /> <img
v-if="previewChannel.bannerUrl"
:src="previewChannel.bannerUrl"
:alt="`${previewChannel.name} banner`"
/>
<v-icon
v-else
class="banner-network-icon"
:icon="networkIcon(form.network)"
/>
</div> </div>
<div class="channel-profile-row"> <div class="channel-profile-row">
<div class="channel-portrait"> <div class="channel-portrait">
{{ channelInitials(previewChannel) }} <img
v-if="previewChannel.portraitUrl"
:src="previewChannel.portraitUrl"
:alt="`${previewChannel.name} portrait`"
/>
<span v-else>{{ channelInitials(previewChannel) }}</span>
</div> </div>
<div> <div>
<strong>{{ previewChannel.name }}</strong> <strong>{{ previewChannel.name }}</strong>
@@ -240,7 +316,7 @@
@submit.prevent="submitForm" @submit.prevent="submitForm"
> >
<div class="panel-header"> <div class="panel-header">
<strong>{{ t('channels.createTitle') }}</strong> <strong>{{ editingChannelId ? t('channels.editTitle') : t('channels.createTitle') }}</strong>
<span>{{ form.network }}</span> <span>{{ form.network }}</span>
</div> </div>
@@ -277,6 +353,44 @@
variant="outlined" variant="outlined"
hide-details hide-details
/> />
<v-text-field
v-model="form.portraitUrl"
:label="t('channels.fields.portraitUrl')"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.bannerUrl"
:label="t('channels.fields.bannerUrl')"
variant="outlined"
hide-details
/>
</div>
<div
v-if="editingChannelId"
class="image-upload-row"
>
<label class="image-upload-button">
<v-icon :icon="mdiImage" />
<span>{{ t('channels.actions.changePortrait') }}</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:disabled="channelsStore.isUpdating"
@change="uploadChannelImage(editingChannel, 'portrait', $event)"
/>
</label>
<label class="image-upload-button">
<v-icon :icon="mdiImage" />
<span>{{ t('channels.actions.changeBanner') }}</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:disabled="channelsStore.isUpdating"
@change="uploadChannelImage(editingChannel, 'banner', $event)"
/>
</label>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
@@ -290,9 +404,12 @@
<v-btn variant="text" :ripple="false" <v-btn variant="text" :ripple="false"
class="primary" class="primary"
type="submit" type="submit"
:disabled="channelsStore.isCreating" :disabled="channelsStore.isCreating || channelsStore.isUpdating"
> >
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }} <v-icon :icon="editingChannelId ? mdiContentSave : mdiPlus" />
<span>
{{ channelsStore.isCreating || channelsStore.isUpdating ? t('common.saving') : (editingChannelId ? t('channels.saveChanges') : t('channels.createTitle')) }}
</span>
</v-btn> </v-btn>
</div> </div>
</form> </form>
@@ -323,7 +440,24 @@
:class="networkClass(channel.network)" :class="networkClass(channel.network)"
> >
<div class="channel-banner"> <div class="channel-banner">
<v-icon :icon="networkIcon(channel.network)" /> <img
v-if="channel.bannerUrl"
:src="channel.bannerUrl"
:alt="`${channel.name} banner`"
/>
<v-icon
v-else
class="banner-network-icon"
:icon="networkIcon(channel.network)"
/>
<button
class="channel-edit-button"
type="button"
:aria-label="`${t('channels.editTitle')} ${channel.name}`"
@click="openEditForm(channel)"
>
<v-icon :icon="mdiPencil" />
</button>
<a <a
v-if="channel.externalUrl" v-if="channel.externalUrl"
:href="channel.externalUrl" :href="channel.externalUrl"
@@ -337,7 +471,12 @@
<div class="channel-profile-row"> <div class="channel-profile-row">
<div class="channel-portrait"> <div class="channel-portrait">
{{ channelInitials(channel) }} <img
v-if="channel.portraitUrl"
:src="channel.portraitUrl"
:alt="`${channel.name} portrait`"
/>
<span v-else>{{ channelInitials(channel) }}</span>
</div> </div>
<div> <div>
<strong>{{ channel.name }}</strong> <strong>{{ channel.name }}</strong>
@@ -451,6 +590,7 @@
} }
.primary { .primary {
@apply gap-2;
background: var(--app-color-on-surface); background: var(--app-color-on-surface);
color: var(--app-color-on-primary); color: var(--app-color-on-primary);
} }
@@ -480,6 +620,21 @@
@apply grid gap-4; @apply grid gap-4;
} }
.image-upload-row {
@apply flex flex-wrap gap-2;
}
.image-upload-button {
@apply inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border px-4 text-sm font-bold;
background: var(--app-control-hover);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.image-upload-button input {
@apply sr-only;
}
.field { .field {
@apply flex flex-col gap-2 text-sm font-semibold; @apply flex flex-col gap-2 text-sm font-semibold;
} }
@@ -507,16 +662,29 @@
color: white; color: white;
} }
.channel-banner :deep(.v-icon:first-child) { .channel-banner img {
@apply h-full w-full object-cover;
}
.banner-network-icon {
@apply text-5xl opacity-90; @apply text-5xl opacity-90;
} }
.channel-edit-button,
.channel-banner a { .channel-banner a {
@apply absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full; @apply absolute right-3 top-3 grid h-8 w-8 place-items-center rounded-full;
background: rgba(255, 255, 255, 0.18); background: rgba(255, 255, 255, 0.18);
color: white; color: white;
} }
.channel-banner a {
@apply top-12;
}
.channel-edit-button {
@apply border-0;
}
.channel-profile-row { .channel-profile-row {
@apply -mt-9 flex items-end gap-3 px-5; @apply -mt-9 flex items-end gap-3 px-5;
} }
@@ -530,12 +698,16 @@
} }
.channel-portrait { .channel-portrait {
@apply grid h-16 w-16 shrink-0 place-items-center rounded-full border-4 text-lg font-black shadow-sm; @apply grid h-16 w-16 shrink-0 place-items-center overflow-hidden rounded-full border-4 text-lg font-black shadow-sm;
background: var(--app-color-on-primary); background: var(--app-color-on-primary);
border-color: var(--app-color-on-primary); border-color: var(--app-color-on-primary);
color: var(--app-color-on-surface); color: var(--app-color-on-surface);
} }
.channel-portrait img {
@apply h-full w-full object-cover;
}
.channel-preview-card > p { .channel-preview-card > p {
@apply px-5 text-sm font-semibold; @apply px-5 text-sm font-semibold;
color: var(--app-text-muted); color: var(--app-text-muted);

View File

@@ -981,6 +981,8 @@
"title": "Channels", "title": "Channels",
"description": "Add channels to the workspace.", "description": "Add channels to the workspace.",
"createTitle": "Create channel", "createTitle": "Create channel",
"editTitle": "Edit channel",
"saveChanges": "Save changes",
"empty": "No channels are available for the active workspace yet.", "empty": "No channels are available for the active workspace yet.",
"emptyAction": "Add a channel for {network}", "emptyAction": "Add a channel for {network}",
"nextDue": "Next due", "nextDue": "Next due",
@@ -989,7 +991,13 @@
"name": "Channel name", "name": "Channel name",
"network": "Network", "network": "Network",
"handle": "Handle", "handle": "Handle",
"externalUrl": "External URL" "externalUrl": "External URL",
"portraitUrl": "Portrait URL",
"bannerUrl": "Banner URL"
},
"actions": {
"changePortrait": "Change portrait",
"changeBanner": "Change banner"
}, },
"metrics": { "metrics": {
"scheduled": "Scheduled", "scheduled": "Scheduled",
@@ -997,7 +1005,8 @@
"blocked": "Blocked" "blocked": "Blocked"
}, },
"errors": { "errors": {
"createFailed": "The channel could not be created." "createFailed": "The channel could not be created.",
"updateFailed": "The channel could not be updated."
} }
}, },
"reviewQueue": { "reviewQueue": {

View File

@@ -981,6 +981,8 @@
"title": "Canaux", "title": "Canaux",
"description": "Ajoutez des canaux à l'espace.", "description": "Ajoutez des canaux à l'espace.",
"createTitle": "Créer un canal", "createTitle": "Créer un canal",
"editTitle": "Modifier le canal",
"saveChanges": "Enregistrer",
"empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.", "empty": "Aucun canal n'est disponible pour l'espace actif pour le moment.",
"emptyAction": "Ajouter un canal pour {network}", "emptyAction": "Ajouter un canal pour {network}",
"nextDue": "Prochaine échéance", "nextDue": "Prochaine échéance",
@@ -989,7 +991,13 @@
"name": "Nom du canal", "name": "Nom du canal",
"network": "Réseau", "network": "Réseau",
"handle": "Identifiant", "handle": "Identifiant",
"externalUrl": "URL externe" "externalUrl": "URL externe",
"portraitUrl": "URL du portrait",
"bannerUrl": "URL de la bannière"
},
"actions": {
"changePortrait": "Changer le portrait",
"changeBanner": "Changer la bannière"
}, },
"metrics": { "metrics": {
"scheduled": "Planifié", "scheduled": "Planifié",
@@ -997,7 +1005,8 @@
"blocked": "Bloqué" "blocked": "Bloqué"
}, },
"errors": { "errors": {
"createFailed": "Le canal n'a pas pu être créé." "createFailed": "Le canal n'a pas pu être créé.",
"updateFailed": "Le canal n'a pas pu être modifié."
} }
}, },
"reviewQueue": { "reviewQueue": {

View File

@@ -3821,6 +3821,195 @@
] ]
} }
}, },
"/api/channels/{id}": {
"put": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersUpdateChannelHandler",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "guid"
}
}
],
"requestBody": {
"x-name": "UpdateChannelRequest",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUpdateChannelRequest"
}
}
},
"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": []
}
]
}
},
"/api/channels/{id}/portrait": {
"post": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersUploadChannelPortraitHandler",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "guid"
}
}
],
"requestBody": {
"x-name": "UploadChannelImageRequest",
"description": "",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUploadChannelImageRequest"
}
}
},
"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": []
}
]
}
},
"/api/channels/{id}/banner": {
"post": {
"tags": [
"Channels",
"Api"
],
"operationId": "SocializeApiModulesChannelsHandlersUploadChannelBannerHandler",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "guid"
}
}
],
"requestBody": {
"x-name": "UploadChannelImageRequest",
"description": "",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesChannelsHandlersUploadChannelImageRequest"
}
}
},
"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": []
}
]
}
},
"/api/campaigns": { "/api/campaigns": {
"post": { "post": {
"tags": [ "tags": [
@@ -7075,6 +7264,14 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"portraitUrl": {
"type": "string",
"nullable": true
},
"bannerUrl": {
"type": "string",
"nullable": true
},
"createdAt": { "createdAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -7118,6 +7315,18 @@
"maxLength": 2048, "maxLength": 2048,
"minLength": 0, "minLength": 0,
"nullable": true "nullable": true
},
"portraitUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
},
"bannerUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
} }
} }
}, },
@@ -7125,6 +7334,72 @@
"type": "object", "type": "object",
"additionalProperties": false "additionalProperties": false
}, },
"SocializeApiModulesChannelsHandlersUpdateChannelRequest": {
"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
},
"portraitUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
},
"bannerUrl": {
"type": "string",
"maxLength": 2048,
"minLength": 0,
"nullable": true
}
}
},
"SocializeApiModulesChannelsHandlersUploadChannelImageRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"nullable": false
}
}
},
"SocializeApiModulesCampaignsHandlersCampaignDto": { "SocializeApiModulesCampaignsHandlersCampaignDto": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,