Merge pull request 'feat: add managed sync with sharepoint' (#2) from stevanfreeborn/feat/add-managed-sync-from-sharepoint into main

This commit was merged in pull request #2.
This commit is contained in:
2026-08-14 03:10:14 +00:00
22 changed files with 1194 additions and 210 deletions
+1
View File
@@ -1,6 +1,7 @@
{ {
"FSharp.suggestGitignore": false, "FSharp.suggestGitignore": false,
"cSpell.words": [ "cSpell.words": [
"Ingestor",
"useparagon" "useparagon"
] ]
} }
-2
View File
@@ -17,8 +17,6 @@ A application harness to explore using Paragon for integrations.
.\scripts\setup-certs.ps1 .\scripts\setup-certs.ps1
``` ```
> **Run this from an elevated (Admin) PowerShell prompt.** `mkcert` needs admin rights to install the CA into the JDK system trust store. Without elevation the OS and browsers still trust the cert, but Java tools will reject it.
This uses [mkcert](https://github.com/FiloSottile/mkcert) (installed automatically if missing). No hosts file entries needed — `*.paragonplayground.localhost` resolves to 127.0.0.1 natively. This uses [mkcert](https://github.com/FiloSottile/mkcert) (installed automatically if missing). No hosts file entries needed — `*.paragonplayground.localhost` resolves to 127.0.0.1 natively.
### 2. Start infrastructure ### 2. Start infrastructure
@@ -56,7 +56,7 @@ internal static class IntegrationEndpoints
HttpContext context, HttpContext context,
OrganizationIntegrationRepository repo, OrganizationIntegrationRepository repo,
UserCredentialRepository credRepo, UserCredentialRepository credRepo,
ParagonService paragon, ParagonApiClient paragon,
CancellationToken ct CancellationToken ct
) )
{ {
@@ -16,7 +16,7 @@ internal static class ParagonEndpoints
private static async Task<IResult> GenerateToken( private static async Task<IResult> GenerateToken(
HttpContext context, HttpContext context,
ParagonService paragon, ParagonApiClient paragon,
UserCredentialRepository credRepo, UserCredentialRepository credRepo,
CancellationToken ct CancellationToken ct
) )
@@ -12,6 +12,34 @@ namespace ParagonPlayground.Api.Endpoints;
internal static class StorageEndpoints internal static class StorageEndpoints
{ {
private static readonly Action<ILogger, Exception?> LogWebhookTestPing =
LoggerMessage.Define(
LogLevel.Information,
new EventId(1, nameof(HandleParagonWebhook)),
"Received Paragon Webhook test/verification ping."
);
private static readonly Action<ILogger, string, Exception?> LogNoRootItemFoundWarning =
LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(2, nameof(HandleParagonWebhook)),
"No root item found in DB for syncId: {SyncId}"
);
private static readonly Action<ILogger, string, string?, string?, Exception?> LogSyncErrored =
LoggerMessage.Define<string, string?, string?>(
LogLevel.Error,
new EventId(3, nameof(HandleParagonWebhook)),
"Sync {SyncId} failed with code {Code}: {Message}"
);
private static readonly Action<ILogger, string, Exception?> LogUnhandledWebhookEvent =
LoggerMessage.Define<string>(
LogLevel.Information,
new EventId(4, nameof(HandleParagonWebhook)),
"Received unhandled Paragon event type: {Event}"
);
internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group) internal static RouteGroupBuilder MapStorageEndpoints(this RouteGroupBuilder group)
{ {
_ = group.MapGet("/", ListItems); _ = group.MapGet("/", ListItems);
@@ -20,10 +48,150 @@ internal static class StorageEndpoints
_ = group.MapDelete("/{id}", DeleteItem); _ = group.MapDelete("/{id}", DeleteItem);
_ = group.MapGet("/{id}/download", GetDownloadUrls); _ = group.MapGet("/{id}/download", GetDownloadUrls);
_ = group.MapGet("/{id}/content", ProxyContent); _ = group.MapGet("/{id}/content", ProxyContent);
_ = group.MapPost("/synced-folders", CreateSyncedFolder);
_ = group.MapPost("/webhook/paragon", HandleParagonWebhook).AllowAnonymous();
_ = group.RequireAuthorization(); _ = group.RequireAuthorization();
return group; return group;
} }
private static async Task<IResult> HandleParagonWebhook(
ParagonWebhookPayload payload,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
SyncHierarchyIngestor syncHierarchyIngestor,
ILoggerFactory loggerFactory,
CancellationToken ct
)
{
var logger = loggerFactory.CreateLogger("ParagonWebhook");
if (payload is null || string.IsNullOrEmpty(payload.SyncInstanceId) || payload.Event is "test" or "ping")
{
LogWebhookTestPing(logger, null);
return Results.Ok(new { status = "ok", message = "Webhook endpoint active" });
}
var rootItem = await repo.GetByManagedSyncIdAsync(payload.SyncInstanceId, ct);
if (rootItem is null)
{
LogNoRootItemFoundWarning(logger, payload.SyncInstanceId, null);
return Results.Ok(new { status = "ignored", reason = "Sync instance not tracked in app" });
}
var jwt = paragon.GenerateToken(rootItem.OrganizationId);
switch (payload.Event)
{
case "sync_complete":
case "record_created":
case "record_updated":
await syncHierarchyIngestor.IngestSyncRecordsAsync(jwt, payload.SyncInstanceId, rootItem, ct);
break;
case "record_deleted":
if (!string.IsNullOrEmpty(payload.Data?.RecordId))
{
await DeleteSyncedRecordAsync(payload.Data.RecordId, rootItem, repo, ct);
}
break;
case "sync_errored":
LogSyncErrored(logger, payload.SyncInstanceId, payload.Error?.Code, payload.Error?.Message, null);
break;
default:
LogUnhandledWebhookEvent(logger, payload.Event, null);
break;
}
return Results.Ok(new { status = "processed" });
}
private static async Task DeleteSyncedRecordAsync(
string recordId,
StorageItem rootItem,
StorageItemRepository repo,
CancellationToken ct
)
{
StorageItem? existing = null;
if (string.IsNullOrWhiteSpace(rootItem.ManagedSyncId) is false)
{
existing = await repo.GetByParagonRecordIdAsync(
rootItem.OrganizationId,
rootItem.ManagedSyncId,
recordId,
ct
);
}
existing ??= await repo.GetBySharePointDriveItemIdAsync(rootItem.OrganizationId, recordId, ct);
if (existing is not null)
{
await repo.DeleteAsync(existing.Id, ct);
}
}
private static async Task<IResult> CreateSyncedFolder(
CreateSyncedFolderRequest request,
HttpContext context,
StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct
)
{
var user = context.GetUser();
var org = context.GetOrganization();
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(
c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
var folderName = await paragon.GetDriveItemNameAsync(
jwt,
spCredential?.CredentialId,
request.SharePointSiteId,
request.SharePointFolderId,
ct
);
var syncId = await paragon.EnableSyncAsync(
jwt,
spCredential?.CredentialId,
request.SharePointFolderId,
request.SharePointSiteId,
ct
);
var rootFolder = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = org.Id,
Name = folderName,
IsFolder = true,
ParentId = request.ParentId,
IsManagedSync = true,
ManagedSyncId = syncId,
SharePointFolderId = request.SharePointFolderId,
SharePointSiteId = request.SharePointSiteId,
IsReadOnly = true,
CreatedByUserId = user.Id,
CreatedAt = DateTime.UtcNow
};
await repo.CreateAsync(rootFolder, ct);
return Results.Created($"/api/storage/{rootFolder.Id}", rootFolder);
}
private static async Task<IResult> ListItems( private static async Task<IResult> ListItems(
string? parentId, string? parentId,
HttpContext context, HttpContext context,
@@ -44,19 +212,7 @@ internal static class StorageEndpoints
users[uid] = u?.DisplayName ?? "Unknown"; users[uid] = u?.DisplayName ?? "Unknown";
} }
return Results.Ok(items.Select(i => new StorageItemResponse return Results.Ok(items.Select(i => StorageItemResponse.From(i, users.GetValueOrDefault(i.CreatedByUserId, "Unknown"))));
{
Id = i.Id,
Name = i.Name,
IsFolder = i.IsFolder,
ParentId = i.ParentId,
ContentType = i.ContentType,
FileSize = i.FileSize,
SharePointWebUrl = i.SharePointWebUrl,
CreatedByUserId = i.CreatedByUserId,
CreatedByDisplayName = users.GetValueOrDefault(i.CreatedByUserId, "Unknown"),
CreatedAt = i.CreatedAt,
}));
} }
private static async Task<IResult> CreateFolder( private static async Task<IResult> CreateFolder(
@@ -87,16 +243,7 @@ internal static class StorageEndpoints
await repo.CreateAsync(item, ct); await repo.CreateAsync(item, ct);
return Results.Created($"/api/storage/{item.Id}", new StorageItemResponse return Results.Created($"/api/storage/{item.Id}", StorageItemResponse.From(item, user.DisplayName));
{
Id = item.Id,
Name = item.Name,
IsFolder = true,
ParentId = item.ParentId,
CreatedByUserId = item.CreatedByUserId,
CreatedByDisplayName = user.DisplayName,
CreatedAt = item.CreatedAt,
});
} }
private static async Task<IResult> UploadFile( private static async Task<IResult> UploadFile(
@@ -104,7 +251,7 @@ internal static class StorageEndpoints
StorageItemRepository storageRepo, StorageItemRepository storageRepo,
UserCredentialRepository credRepo, UserCredentialRepository credRepo,
OrganizationIntegrationRepository configRepo, OrganizationIntegrationRepository configRepo,
ParagonService paragon, ParagonApiClient paragon,
UserRepository userRepo, UserRepository userRepo,
CancellationToken ct CancellationToken ct
) )
@@ -206,42 +353,71 @@ internal static class StorageEndpoints
await storageRepo.CreateAsync(storageItem, ct); await storageRepo.CreateAsync(storageItem, ct);
return Results.Created($"/api/storage/{storageItem.Id}", new StorageItemResponse return Results.Created($"/api/storage/{storageItem.Id}", StorageItemResponse.From(storageItem, user.DisplayName));
{
Id = storageItem.Id,
Name = storageItem.Name,
IsFolder = false,
ParentId = storageItem.ParentId,
ContentType = storageItem.ContentType,
FileSize = storageItem.FileSize,
SharePointWebUrl = storageItem.SharePointWebUrl,
CreatedByUserId = storageItem.CreatedByUserId,
CreatedByDisplayName = user.DisplayName,
CreatedAt = storageItem.CreatedAt,
});
} }
private static async Task<IResult> DeleteItem( private static async Task<IResult> DeleteItem(
string id, string id,
HttpContext context, HttpContext context,
StorageItemRepository repo, StorageItemRepository repo,
UserCredentialRepository credRepo,
ParagonApiClient paragon,
CancellationToken ct CancellationToken ct
) )
{ {
var user = context.GetUser();
var org = context.GetOrganization(); var org = context.GetOrganization();
var item = await repo.GetByIdAsync(id, ct); var item = await repo.GetByIdAsync(id, ct);
if (item is null || item.OrganizationId != org.Id) if (item is null)
{ {
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); return Results.NotFound();
}
if (item.IsReadOnly && !item.IsManagedSync)
{
return Results.Problem(detail: "Contents of a managed sync folder are read-only.", statusCode: StatusCodes.Status403Forbidden);
}
if (item.IsManagedSync && !string.IsNullOrEmpty(item.ManagedSyncId))
{
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c => c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase));
var jwt = paragon.GenerateToken(org.Id, spCredential?.CredentialId);
await paragon.DeleteSyncAsync(jwt, item.ManagedSyncId, ct);
await DeleteFolderRecursiveAsync(item.Id, repo, ct);
return Results.NoContent();
} }
await repo.DeleteAsync(id, ct); await repo.DeleteAsync(id, ct);
return Results.NoContent(); return Results.NoContent();
} }
private static async Task DeleteFolderRecursiveAsync(
string folderId,
StorageItemRepository repo,
CancellationToken ct
)
{
var children = await repo.GetChildrenAsync(folderId, ct);
foreach (var child in children)
{
if (child.IsFolder)
{
await DeleteFolderRecursiveAsync(child.Id, repo, ct);
}
else
{
await repo.DeleteAsync(child.Id, ct);
}
}
await repo.DeleteAsync(folderId, ct);
}
private static async Task<IResult> GetDownloadUrls( private static async Task<IResult> GetDownloadUrls(
string id, string id,
HttpContext context, HttpContext context,
@@ -272,11 +448,10 @@ internal static class StorageEndpoints
HttpContext context, HttpContext context,
StorageItemRepository storageRepo, StorageItemRepository storageRepo,
UserCredentialRepository credRepo, UserCredentialRepository credRepo,
ParagonService paragon, ParagonApiClient paragon,
CancellationToken ct CancellationToken ct
) )
{ {
var user = context.GetUser();
var org = context.GetOrganization(); var org = context.GetOrganization();
var item = await storageRepo.GetByIdAsync(id, ct); var item = await storageRepo.GetByIdAsync(id, ct);
@@ -286,27 +461,6 @@ internal static class StorageEndpoints
return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound); return Results.Problem(detail: "Item not found", statusCode: StatusCodes.Status404NotFound);
} }
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
{
return Results.Problem(
detail: "No SharePoint reference available for this file.",
statusCode: StatusCodes.Status400BadRequest
);
}
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
if (spCredential is null)
{
return Results.Problem(
detail: "No SharePoint credential found.",
statusCode: StatusCodes.Status400BadRequest
);
}
if (paragon.IsConfigured is false) if (paragon.IsConfigured is false)
{ {
return Results.Problem( return Results.Problem(
@@ -315,14 +469,56 @@ internal static class StorageEndpoints
); );
} }
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId); Stream fileStream;
var fileStream = await paragon.DownloadFileAsync(
jwt, if (
spCredential.CredentialId, string.IsNullOrWhiteSpace(item.ManagedSyncId) is false
item.SharePointSiteId, && string.IsNullOrWhiteSpace(item.ParagonRecordId) is false
item.SharePointDriveItemId, )
ct {
); var jwt = paragon.GenerateToken(org.Id);
fileStream = await paragon.DownloadSyncedRecordContentAsync(
jwt,
item.ManagedSyncId,
item.ParagonRecordId,
ct
);
}
else
{
if (string.IsNullOrEmpty(item.SharePointDriveItemId) || string.IsNullOrEmpty(item.SharePointSiteId))
{
return Results.Problem(
detail: "No SharePoint reference available for this file.",
statusCode: StatusCodes.Status400BadRequest
);
}
var user = context.GetUser();
var credentials = await credRepo.GetByUserIdAsync(user.Id, ct);
var spCredential = credentials.FirstOrDefault(c =>
c.IntegrationType.Equals("sharepoint", StringComparison.OrdinalIgnoreCase)
);
if (spCredential is null)
{
return Results.Problem(
detail: "No SharePoint credential found.",
statusCode: StatusCodes.Status400BadRequest
);
}
var jwt = paragon.GenerateToken(org.Id, spCredential.CredentialId);
fileStream = await paragon.DownloadFileAsync(
jwt,
spCredential.CredentialId,
item.SharePointSiteId,
item.SharePointDriveItemId,
ct
);
}
return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name); return Results.Stream(fileStream, item.ContentType ?? "application/octet-stream", item.Name);
} }
@@ -28,7 +28,7 @@ builder.Services.AddSingleton<SessionRepository>();
builder.Services.AddSingleton<StorageItemRepository>(); builder.Services.AddSingleton<StorageItemRepository>();
builder.Services.AddSingleton<UserCredentialRepository>(); builder.Services.AddSingleton<UserCredentialRepository>();
builder.Services.AddSingleton<OrganizationIntegrationRepository>(); builder.Services.AddSingleton<OrganizationIntegrationRepository>();
builder.Services.AddSingleton<ParagonService>(); builder.Services.AddSingleton<SyncHierarchyIngestor>();
builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName)); builder.Services.Configure<ParagonOptions>(builder.Configuration.GetSection(ParagonOptions.SectionName));
@@ -1,22 +1,145 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ParagonPlayground.Api.Options; using ParagonPlayground.Api.Options;
using ParagonPlayground.Domain.DTOs;
namespace ParagonPlayground.Api.Services; namespace ParagonPlayground.Api.Services;
internal sealed class ParagonApiClient internal sealed class ParagonApiClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly string _projectId; private readonly string _signingKey;
public string ProjectId { get; }
public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options) public ParagonApiClient(HttpClient httpClient, IOptions<ParagonOptions> options)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_projectId = options.Value.ProjectId; var paragonOptions = options.Value;
_httpClient.BaseAddress = new Uri(options.Value.ProxyBaseUrl.TrimEnd('/') + "/"); ProjectId = paragonOptions.ProjectId;
_signingKey = paragonOptions.SigningKey;
_httpClient.BaseAddress = new Uri(paragonOptions.ProxyBaseUrl.TrimEnd('/') + "/");
}
public bool IsConfigured =>
string.IsNullOrEmpty(ProjectId) is false
&& string.IsNullOrEmpty(_signingKey) is false;
public string GenerateToken(string organizationId, string? credentialId = null)
{
if (IsConfigured is false)
{
throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(_signingKey);
var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" };
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var permissions = credentialId is not null
? new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
[$"credential:{credentialId}"] = true,
},
}
: (object)new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
["credential:*"] = new[] { "credential:write" },
},
};
var claims = new[]
{
new Claim("sub", $"org:{organizationId}"),
new Claim("aud", $"useparagon.com/{ProjectId}"),
new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)),
};
var token = new JwtSecurityToken(
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public async Task<string> GetDriveItemNameAsync(
string jwt,
string? credentialId,
string siteId,
string folderId,
CancellationToken ct
)
{
var url = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{siteId}/drive/items/{folderId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
if (string.IsNullOrEmpty(credentialId) is false)
{
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
}
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("output", out var output) && output.TryGetProperty("name", out var nameProp))
{
return nameProp.GetString() ?? "Synced SharePoint Folder";
}
return "Synced SharePoint Folder";
}
public async Task<SyncedRecordsResponse> PullSyncedRecordsAsync(
string jwt,
string syncId,
string? cursor,
int pageSize,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records?pageSize={pageSize}";
if (string.IsNullOrEmpty(cursor) is false)
{
url += $"&cursor={Uri.EscapeDataString(cursor)}";
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<SyncedRecordsResponse>(json, JsonOptions) ?? new SyncedRecordsResponse();
} }
public async Task<string> UploadFileAsync( public async Task<string> UploadFileAsync(
@@ -30,7 +153,7 @@ internal sealed class ParagonApiClient
CancellationToken ct CancellationToken ct
) )
{ {
var url = $"projects/{_projectId}/sdk/proxy/sharepoint" var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content"; + $"/sites/{siteId}/drive/root:/{folderPath.Trim('/')}/{fileName}:/content";
using var ms = new MemoryStream(); using var ms = new MemoryStream();
@@ -60,13 +183,31 @@ internal sealed class ParagonApiClient
CancellationToken ct CancellationToken ct
) )
{ {
var url = $"projects/{_projectId}/sdk/proxy/sharepoint" var url = $"projects/{ProjectId}/sdk/proxy/sharepoint"
+ $"/sites/{siteId}/drive/items/{driveItemId}/content"; + $"/sites/{siteId}/drive/items/{driveItemId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId); _ = request.Headers.TryAddWithoutValidation("X-Paragon-Credential", credentialId);
_ = request.Headers.TryAddWithoutValidation("X-Paragon-Use-Raw-Response", "1");
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
}
public async Task<Stream> DownloadSyncedRecordContentAsync(
string jwt,
string syncId,
string recordId,
CancellationToken ct
)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}/records/{recordId}/content";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode(); _ = response.EnsureSuccessStatusCode();
@@ -88,7 +229,7 @@ internal sealed class ParagonApiClient
? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString)) ? ":/" + string.Join("/", segments.Select(Uri.EscapeDataString))
: ""; : "";
var proxyUrl = $"projects/{_projectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}"; var proxyUrl = $"projects/{ProjectId}/sdk/proxy/sharepoint/sites/{uri.Host}{encodedPath}";
using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl); using var request = new HttpRequestMessage(HttpMethod.Get, proxyUrl);
_ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}"); _ = request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {jwt}");
@@ -104,4 +245,49 @@ internal sealed class ParagonApiClient
var idProp = output.GetProperty("id"); var idProp = output.GetProperty("id");
return idProp.GetString() ?? ""; return idProp.GetString() ?? "";
} }
public async Task<string> EnableSyncAsync(
string jwt,
string? credentialId,
string folderId,
string siteId,
CancellationToken ct
)
{
var url = "https://sync.useparagon.com/api/syncs";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new("Bearer", jwt);
var payload = new
{
integration = "sharepoint",
pipeline = "files",
credentialId,
configuration = new
{
folderId,
siteId,
},
};
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("id").GetString() ?? throw new InvalidOperationException("Failed to obtain sync ID");
}
public async Task DeleteSyncAsync(string jwt, string syncId, CancellationToken ct)
{
var url = $"https://sync.useparagon.com/api/syncs/{syncId}";
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Authorization = new("Bearer", jwt);
using var response = await _httpClient.SendAsync(request, ct).ConfigureAwait(false);
_ = response.EnsureSuccessStatusCode();
}
} }
@@ -1,106 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ParagonPlayground.Api.Options;
namespace ParagonPlayground.Api.Services;
internal sealed class ParagonService(IOptions<ParagonOptions> options, ParagonApiClient apiClient)
{
private readonly ParagonOptions _options = options.Value;
private readonly ParagonApiClient _apiClient = apiClient;
public string ProjectId => _options.ProjectId;
public bool IsConfigured =>
string.IsNullOrEmpty(_options.ProjectId) is false
&& string.IsNullOrEmpty(_options.SigningKey) is false;
public string GenerateToken(string organizationId, string? credentialId = null)
{
if (IsConfigured is false)
{
throw new InvalidOperationException("Paragon is not configured. Set Paragon:ProjectId and Paragon:SigningKey.");
}
using var rsa = RSA.Create();
rsa.ImportFromPem(_options.SigningKey);
var key = new RsaSecurityKey(rsa.ExportParameters(true)) { KeyId = "paragon" };
var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
var now = DateTime.UtcNow;
var permissions = credentialId is not null
? new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
[$"credential:{credentialId}"] = true,
},
}
: (object)new Dictionary<string, object>
{
["integration:sharepoint"] = new Dictionary<string, object>
{
["credential:*"] = new[] { "credential:write" },
},
};
var claims = new[]
{
new Claim("sub", $"org:{organizationId}"),
new Claim("aud", $"useparagon.com/{_options.ProjectId}"),
new Claim("urn:useparagon:connect:permissions", JsonSerializer.Serialize(permissions)),
};
var token = new JwtSecurityToken(
claims: claims,
notBefore: now,
expires: now.AddHours(1),
signingCredentials: signingCredentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public Task<string> UploadFileAsync(
string jwt,
string credentialId,
string siteId,
string folderPath,
string fileName,
Stream fileStream,
string contentType,
CancellationToken ct
)
{
return _apiClient.UploadFileAsync(jwt, credentialId, siteId, folderPath, fileName, fileStream, contentType, ct);
}
public Task<string> ResolveSiteUrlAsync(
string jwt,
string credentialId,
string siteUrl,
CancellationToken ct
)
{
return _apiClient.ResolveSiteUrlAsync(jwt, credentialId, siteUrl, ct);
}
public Task<Stream> DownloadFileAsync(
string jwt,
string credentialId,
string siteId,
string driveItemId,
CancellationToken ct
)
{
return _apiClient.DownloadFileAsync(jwt, credentialId, siteId, driveItemId, ct);
}
}
@@ -0,0 +1,298 @@
using MongoDB.Bson;
using ParagonPlayground.Domain.DTOs;
using ParagonPlayground.Domain.Entities;
using ParagonPlayground.Infrastructure.Data;
namespace ParagonPlayground.Api.Services;
internal sealed class SyncHierarchyIngestor(StorageItemRepository repo, ParagonApiClient paragon)
{
private readonly StorageItemRepository _repo = repo;
private readonly ParagonApiClient _paragon = paragon;
public async Task IngestSyncRecordsAsync(
string jwt,
string syncId,
StorageItem rootItem,
CancellationToken ct
)
{
var fetchedRecords = await FetchRecordsAsync(jwt, syncId, ct);
var normalizedRecords = NormalizeRecords(fetchedRecords, rootItem);
if (normalizedRecords.All.Count == 0)
{
return;
}
var existingState = await LoadExistingStateAsync(rootItem.OrganizationId, normalizedRecords.ExternalIds, ct);
var folderResult = await PersistFoldersAsync(rootItem, syncId, normalizedRecords.Folders, existingState, ct);
await PersistUnresolvedFoldersAtRootAsync(rootItem, syncId, folderResult.UnresolvedFolders, existingState, ct);
await PersistFilesAsync(rootItem, syncId, normalizedRecords.Files, folderResult.FolderStorageIdByExternalId, existingState, ct);
}
private async Task<IReadOnlyList<SyncedRecordItem>> FetchRecordsAsync(
string jwt,
string syncId,
CancellationToken ct
)
{
string? cursor = null;
var hasMore = true;
var records = new List<SyncedRecordItem>();
while (hasMore && !ct.IsCancellationRequested)
{
var response = await _paragon.PullSyncedRecordsAsync(jwt, syncId, cursor, 100, ct);
records.AddRange(response.Data);
cursor = response.Paging?.Cursor;
hasMore = (response.Paging?.RemainingRecords ?? 0) > 0 && !string.IsNullOrEmpty(cursor);
}
return records;
}
private static NormalizedRecordSet NormalizeRecords(
IReadOnlyList<SyncedRecordItem> records,
StorageItem rootItem
)
{
var all = records
.Where(r => string.IsNullOrWhiteSpace(r.ExternalId) is false)
.GroupBy(r => r.ExternalId, StringComparer.Ordinal)
.Select(g => g.Last())
.Where(r => IsRootRecord(r, rootItem) is false)
.ToList();
return new NormalizedRecordSet(
All: all,
Folders: [.. all.Where(r => r.IsFolder())],
Files: [.. all.Where(r => r.IsFolder() is false)],
ExternalIds: [.. all.Select(r => r.ExternalId).Distinct(StringComparer.Ordinal)]
);
}
private async Task<ExistingStorageState> LoadExistingStateAsync(
string organizationId,
IReadOnlyList<string> externalIds,
CancellationToken ct
)
{
var existingItems = await _repo.GetBySharePointDriveItemIdsAsync(organizationId, externalIds, ct);
return ExistingStorageState.FromItems(existingItems);
}
private async Task<FolderResolutionResult> PersistFoldersAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> folders,
ExistingStorageState state,
CancellationToken ct
)
{
var pending = folders.ToList();
var folderStorageIdByExternalId = new Dictionary<string, string>(state.FolderStorageIdByExternalId, StringComparer.Ordinal);
while (pending.Count > 0 && !ct.IsCancellationRequested)
{
var progressMade = false;
var nextPending = new List<SyncedRecordItem>();
foreach (var folder in pending)
{
if (TryResolveParentId(rootItem, folder, folderStorageIdByExternalId, out var parentId) is false)
{
nextPending.Add(folder);
continue;
}
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, parentId, state.ItemsByExternalId, ct);
folderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
progressMade = true;
}
pending = nextPending;
if (progressMade is false)
{
break;
}
}
return new FolderResolutionResult(folderStorageIdByExternalId, pending);
}
private async Task PersistUnresolvedFoldersAtRootAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> unresolvedFolders,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var folder in unresolvedFolders)
{
var persisted = await UpsertStorageItemAsync(rootItem, syncId, folder, isFolder: true, rootItem.Id, state.ItemsByExternalId, ct);
state.FolderStorageIdByExternalId[folder.ExternalId] = persisted.Id;
}
}
private async Task PersistFilesAsync(
StorageItem rootItem,
string syncId,
IReadOnlyList<SyncedRecordItem> files,
Dictionary<string, string> folderStorageIdByExternalId,
ExistingStorageState state,
CancellationToken ct
)
{
foreach (var file in files)
{
var parentId = rootItem.Id;
if (
IsRootParent(rootItem, file) is false
&& folderStorageIdByExternalId.TryGetValue(file.ParentFolderId, out var resolvedParentId)
)
{
parentId = resolvedParentId;
}
_ = await UpsertStorageItemAsync(rootItem, syncId, file, isFolder: false, parentId, state.ItemsByExternalId, ct);
}
}
private async Task<StorageItem> UpsertStorageItemAsync(
StorageItem rootItem,
string syncId,
SyncedRecordItem record,
bool isFolder,
string parentId,
Dictionary<string, StorageItem> itemsByExternalId,
CancellationToken ct
)
{
if (itemsByExternalId.TryGetValue(record.ExternalId, out var existing))
{
existing.Name = record.Name;
existing.IsFolder = isFolder;
existing.ParentId = parentId;
existing.ContentType = isFolder ? null : record.MimeType;
existing.FileSize = isFolder ? 0 : record.Size;
existing.SharePointSiteId = rootItem.SharePointSiteId;
existing.SharePointDriveItemId = record.ExternalId;
existing.SharePointWebUrl = record.Url;
existing.IsManagedSync = false;
existing.ManagedSyncId = syncId;
existing.ParagonRecordId = record.Id;
existing.IsReadOnly = true;
await _repo.UpdateAsync(existing, ct);
return existing;
}
var newItem = new StorageItem
{
Id = ObjectId.GenerateNewId().ToString(),
OrganizationId = rootItem.OrganizationId,
Name = record.Name,
IsFolder = isFolder,
ParentId = parentId,
ContentType = isFolder ? null : record.MimeType,
FileSize = isFolder ? 0 : record.Size,
SharePointSiteId = rootItem.SharePointSiteId,
SharePointDriveItemId = record.ExternalId,
SharePointWebUrl = record.Url,
IsManagedSync = false,
ManagedSyncId = syncId,
ParagonRecordId = record.Id,
IsReadOnly = true,
CreatedByUserId = rootItem.CreatedByUserId,
CreatedAt = DateTime.UtcNow
};
await _repo.CreateAsync(newItem, ct);
itemsByExternalId[record.ExternalId] = newItem;
return newItem;
}
private static bool TryResolveParentId(
StorageItem rootItem,
SyncedRecordItem record,
Dictionary<string, string> folderStorageIdByExternalId,
out string parentId
)
{
if (IsRootParent(rootItem, record))
{
parentId = rootItem.Id;
return true;
}
if (folderStorageIdByExternalId.TryGetValue(record.ParentFolderId, out var resolvedParentId))
{
parentId = resolvedParentId;
return true;
}
parentId = string.Empty;
return false;
}
private static bool IsRootRecord(SyncedRecordItem record, StorageItem rootItem)
{
return string.Equals(record.ExternalId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ExternalId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private static bool IsRootParent(StorageItem rootItem, SyncedRecordItem record)
{
return string.IsNullOrWhiteSpace(record.ParentFolderId)
|| string.Equals(record.ParentFolderId, rootItem.SharePointFolderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(record.ParentFolderId, rootItem.SharePointDriveItemId, StringComparison.OrdinalIgnoreCase);
}
private sealed record NormalizedRecordSet(
IReadOnlyList<SyncedRecordItem> All,
IReadOnlyList<SyncedRecordItem> Folders,
IReadOnlyList<SyncedRecordItem> Files,
IReadOnlyList<string> ExternalIds
);
private sealed class ExistingStorageState
{
public Dictionary<string, StorageItem> ItemsByExternalId { get; }
public Dictionary<string, string> FolderStorageIdByExternalId { get; }
private ExistingStorageState(
Dictionary<string, StorageItem> itemsByExternalId,
Dictionary<string, string> folderStorageIdByExternalId
)
{
ItemsByExternalId = itemsByExternalId;
FolderStorageIdByExternalId = folderStorageIdByExternalId;
}
public static ExistingStorageState FromItems(IEnumerable<StorageItem> items)
{
var itemList = items.ToList();
var itemsByExternalId = itemList
.Where(i => string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, StringComparer.Ordinal);
var folderStorageIdByExternalId = itemList
.Where(i => i.IsFolder && string.IsNullOrWhiteSpace(i.SharePointDriveItemId) is false)
.ToDictionary(i => i.SharePointDriveItemId!, i => i.Id, StringComparer.Ordinal);
return new ExistingStorageState(itemsByExternalId, folderStorageIdByExternalId);
}
}
private sealed record FolderResolutionResult(
Dictionary<string, string> FolderStorageIdByExternalId,
IReadOnlyList<SyncedRecordItem> UnresolvedFolders
);
}
@@ -12,9 +12,12 @@ namespace ParagonPlayground.Cli.Commands;
internal class SeedCommand( internal class SeedCommand(
OrganizationRepository orgRepo, OrganizationRepository orgRepo,
UserRepository userRepo, UserRepository userRepo,
PasswordService passwordService) : AsyncCommand<SeedCommand.Settings> PasswordService passwordService
) : AsyncCommand<SeedCommand.Settings>
{ {
internal class Settings : CommandSettings { } internal class Settings : CommandSettings
{
}
protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) protected override async Task<int> ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken)
{ {
@@ -0,0 +1,17 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Represents a request to sync a folder from a sharepoint site.</summary>
public class CreateSyncedFolderRequest
{
/// <summary>The sharepoint id for the folder that will be synced.</summary>
public required string SharePointFolderId { get; set; }
/// <summary>The id of the sharepoint site where the folder to be synced exists.</summary>
public required string SharePointSiteId { get; set; }
/// <summary>The id of the parent id for the folder being synced.</summary>
public string ParentId { get; set; } = string.Empty;
/// <summary>The associated credentials with the folder sync.</summary>
public string CredentialId { get; set; } = string.Empty;
}
@@ -0,0 +1,60 @@
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Webhook payload sent by Paragon for managed sync lifecycle events.</summary>
public class ParagonWebhookPayload
{
/// <summary>Webhook event type (for example: sync_complete, record_updated, record_deleted).</summary>
public string Event { get; set; } = string.Empty;
/// <summary>Paragon managed sync instance identifier associated with this event.</summary>
public string SyncInstanceId { get; set; } = string.Empty;
/// <summary>Sync descriptor from the webhook payload, when provided.</summary>
public string Sync { get; set; } = string.Empty;
/// <summary>Credential identifier used by the integration, when included by Paragon.</summary>
public string CredentialId { get; set; } = string.Empty;
/// <summary>User context attached to the webhook payload.</summary>
public ParagonWebhookUser? User { get; set; }
/// <summary>Event-specific data payload.</summary>
public ParagonWebhookData? Data { get; set; }
/// <summary>Error payload when the webhook event represents a failure state.</summary>
public ParagonWebhookError? Error { get; set; }
}
/// <summary>User metadata included in a Paragon webhook.</summary>
public class ParagonWebhookUser
{
/// <summary>Paragon user identifier for the connected user.</summary>
public string Id { get; set; } = string.Empty;
}
/// <summary>Event data section of a Paragon webhook payload.</summary>
public class ParagonWebhookData
{
/// <summary>Logical model type for the emitted record event.</summary>
public string Model { get; set; } = string.Empty;
/// <summary>Record identifier relevant to create/update/delete record events.</summary>
public string? RecordId { get; set; }
/// <summary>Timestamp indicating when the sync data was last synchronized.</summary>
public string? SyncedAt { get; set; }
/// <summary>Number of records referenced by the event when available.</summary>
public int? NumRecords { get; set; }
}
/// <summary>Error details included with failed sync webhook events.</summary>
public class ParagonWebhookError
{
/// <summary>Provider-specific error code.</summary>
public string? Code { get; set; }
/// <summary>Human-readable error description.</summary>
public string? Message { get; set; }
}
@@ -1,3 +1,5 @@
using ParagonPlayground.Domain.Entities;
namespace ParagonPlayground.Domain.DTOs; namespace ParagonPlayground.Domain.DTOs;
/// <summary>File or folder returned by the storage API.</summary> /// <summary>File or folder returned by the storage API.</summary>
@@ -24,6 +26,12 @@ public class StorageItemResponse
/// <summary>Direct SharePoint web URL (null for folders).</summary> /// <summary>Direct SharePoint web URL (null for folders).</summary>
public string? SharePointWebUrl { get; set; } public string? SharePointWebUrl { get; set; }
/// <summary>Indicates if the storage item is associated with a managed sync.</summary>
public bool IsManagedSync { get; set; }
/// <summary>Indicates if the storage item can be edited by our application.</summary>
public bool IsReadOnly { get; set; }
/// <summary>User who created this item.</summary> /// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty; public string CreatedByUserId { get; set; } = string.Empty;
@@ -32,4 +40,27 @@ public class StorageItemResponse
/// <summary>Timestamp when the item was created.</summary> /// <summary>Timestamp when the item was created.</summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>Creates a response DTO from a storage item entity.</summary>
public static StorageItemResponse From(StorageItem item, string createdByDisplayName)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(createdByDisplayName);
return new StorageItemResponse
{
Id = item.Id,
Name = item.Name,
IsFolder = item.IsFolder,
ParentId = item.ParentId,
ContentType = item.ContentType,
FileSize = item.FileSize,
SharePointWebUrl = item.SharePointWebUrl,
IsManagedSync = item.IsManagedSync,
IsReadOnly = item.IsReadOnly,
CreatedByUserId = item.CreatedByUserId,
CreatedByDisplayName = createdByDisplayName,
CreatedAt = item.CreatedAt,
};
}
} }
@@ -0,0 +1,84 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace ParagonPlayground.Domain.DTOs;
/// <summary>Response payload returned by the Paragon Sync API GET /api/syncs/{syncId}/records endpoint.</summary>
public class SyncedRecordsResponse
{
/// <summary>Synced records returned for the requested page.</summary>
[JsonPropertyName("data")]
public Collection<SyncedRecordItem> Data { get; init; } = [];
/// <summary>Paging metadata used to continue retrieving additional records.</summary>
[JsonPropertyName("paging")]
public SyncedRecordsPaging Paging { get; set; } = new();
}
/// <summary>Pagination metadata returned with synced record batches.</summary>
public class SyncedRecordsPaging
{
/// <summary>Total number of records known to the sync.</summary>
[JsonPropertyName("totalRecords")]
public int TotalRecords { get; set; }
/// <summary>Total number of active (non-deleted) records in the sync.</summary>
[JsonPropertyName("totalActiveRecords")]
public int TotalActiveRecords { get; set; }
/// <summary>Count of records still remaining after the current page.</summary>
[JsonPropertyName("remainingRecords")]
public int RemainingRecords { get; set; }
/// <summary>Cursor to request the next page of records.</summary>
[JsonPropertyName("cursor")]
public string? Cursor { get; set; }
/// <summary>Unix timestamp of the latest record observed in this page.</summary>
[JsonPropertyName("lastSeen")]
public long LastSeen { get; set; }
}
/// <summary>Represents a single synced file or record item returned by Paragon Managed Sync.</summary>
public class SyncedRecordItem
{
/// <summary>Paragon-generated sync record identifier.</summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>Provider-native record identifier (for example, SharePoint drive item ID).</summary>
[JsonPropertyName("externalId")]
public string ExternalId { get; set; } = string.Empty;
/// <summary>Display name for the synced file or folder.</summary>
[JsonPropertyName("name")]
public string Name { get; set; } = "Untitled Item";
/// <summary>MIME type of the synced file; empty for folders.</summary>
[JsonPropertyName("mimeType")]
public string MimeType { get; set; } = "application/octet-stream";
/// <summary>File size in bytes.</summary>
[JsonPropertyName("size")]
public long Size { get; set; }
/// <summary>Provider URL for viewing the record in the source system.</summary>
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
/// <summary>External identifier of the record's parent folder.</summary>
[JsonPropertyName("parentFolderId")]
public string ParentFolderId { get; set; } = string.Empty;
/// <summary>Integration-specific metadata attached to the synced record.</summary>
[JsonPropertyName("customFields")]
public Dictionary<string, object>? CustomFields { get; init; }
/// <summary>Determines whether the synced item represents a folder.</summary>
public bool IsFolder()
{
return string.IsNullOrEmpty(MimeType);
}
}
@@ -24,6 +24,7 @@ public class StorageItem
/// <summary>File size in bytes (0 for folders).</summary> /// <summary>File size in bytes (0 for folders).</summary>
public long FileSize { get; set; } public long FileSize { get; set; }
/// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary> /// <summary>SharePoint site ID where the file was uploaded (null for folders).</summary>
public string? SharePointSiteId { get; set; } public string? SharePointSiteId { get; set; }
@@ -33,6 +34,23 @@ public class StorageItem
/// <summary>SharePoint web URL for direct access (null for folders).</summary> /// <summary>SharePoint web URL for direct access (null for folders).</summary>
public string? SharePointWebUrl { get; set; } public string? SharePointWebUrl { get; set; }
/// <summary>Indicates whether the storage item is associated with a managed sync</summary>
public bool IsManagedSync { get; set; }
/// <summary>Identifies the managed sync this storage item is attached to.</summary>
public string ManagedSyncId { get; set; } = string.Empty;
/// <summary>Paragon Sync record ID (sync-generated UUID) for items ingested from managed sync.</summary>
public string ParagonRecordId { get; set; } = string.Empty;
/// <summary>Identifies the sharepoint folder id that the managed sync is attached to.</summary>
public string SharePointFolderId { get; set; } = string.Empty;
/// <summary>Indicates whether the storage item can be edited or modified by our application.</summary>
public bool IsReadOnly { get; set; }
/// <summary>User who created this item.</summary> /// <summary>User who created this item.</summary>
public string CreatedByUserId { get; set; } = string.Empty; public string CreatedByUserId { get; set; } = string.Empty;
@@ -11,7 +11,10 @@ public class StorageItemRepository(MongoDbContext context)
/// <summary>Lists items in a folder (or root items when parentId is null).</summary> /// <summary>Lists items in a folder (or root items when parentId is null).</summary>
public async Task<List<StorageItem>> GetByParentIdAsync( public async Task<List<StorageItem>> GetByParentIdAsync(
string organizationId, string? parentId, CancellationToken ct) string organizationId,
string? parentId,
CancellationToken ct
)
{ {
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId) var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId); & Builders<StorageItem>.Filter.Eq(i => i.ParentId, parentId);
@@ -23,6 +26,14 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>Lists all direct child items of a given parent folder.</summary>
public async Task<List<StorageItem>> GetChildrenAsync(string parentId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ParentId == parentId)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by ID.</summary> /// <summary>Finds a storage item by ID.</summary>
public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct) public async Task<StorageItem?> GetByIdAsync(string id, CancellationToken ct)
{ {
@@ -31,6 +42,68 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>Finds a root storage item by Paragon Managed Sync ID.</summary>
public async Task<StorageItem?> GetByManagedSyncIdAsync(string managedSyncId, CancellationToken ct)
{
return await _context.StorageItems.Find(i => i.ManagedSyncId == managedSyncId)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a storage item by organization ID and SharePoint Drive Item ID.</summary>
public async Task<StorageItem?> GetBySharePointDriveItemIdAsync(
string organizationId,
string sharePointDriveItemId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.SharePointDriveItemId, sharePointDriveItemId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds a synced storage item by organization, sync ID, and Paragon record ID.</summary>
public async Task<StorageItem?> GetByParagonRecordIdAsync(
string organizationId,
string managedSyncId,
string paragonRecordId,
CancellationToken ct
)
{
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.Eq(i => i.ManagedSyncId, managedSyncId)
& Builders<StorageItem>.Filter.Eq(i => i.ParagonRecordId, paragonRecordId);
return await _context.StorageItems.Find(filter)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Finds storage items by organization ID and SharePoint Drive Item IDs.</summary>
public async Task<List<StorageItem>> GetBySharePointDriveItemIdsAsync(
string organizationId,
IReadOnlyCollection<string> sharePointDriveItemIds,
CancellationToken ct
)
{
ArgumentNullException.ThrowIfNull(sharePointDriveItemIds);
if (sharePointDriveItemIds.Count == 0)
{
return [];
}
var filter = Builders<StorageItem>.Filter.Eq(i => i.OrganizationId, organizationId)
& Builders<StorageItem>.Filter.In(i => i.SharePointDriveItemId, sharePointDriveItemIds);
return await _context.StorageItems.Find(filter)
.ToListAsync(ct)
.ConfigureAwait(false);
}
/// <summary>Creates a new storage item.</summary> /// <summary>Creates a new storage item.</summary>
public async Task CreateAsync(StorageItem item, CancellationToken ct) public async Task CreateAsync(StorageItem item, CancellationToken ct)
{ {
@@ -41,6 +114,16 @@ public class StorageItemRepository(MongoDbContext context)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <summary>Updates an existing storage item.</summary>
public async Task UpdateAsync(StorageItem item, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(item);
_ = await _context.StorageItems
.ReplaceOneAsync(i => i.Id == item.Id, item, cancellationToken: ct)
.ConfigureAwait(false);
}
/// <summary>Deletes a storage item by ID.</summary> /// <summary>Deletes a storage item by ID.</summary>
public async Task DeleteAsync(string id, CancellationToken ct) public async Task DeleteAsync(string id, CancellationToken ct)
{ {
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
type IconName = 'folder' | 'file' | 'upload' | 'download' | 'external' | 'trash' | 'x' | 'plus'; type IconName =
'folder' | 'file' | 'upload' | 'download' | 'external' | 'trash' | 'x' | 'plus' | 'sharepoint';
const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), { const props = withDefaults(defineProps<{ name: IconName; size?: number }>(), {
size: 16, size: 16,
@@ -24,6 +25,11 @@
], ],
x: ['M18 6 6 18', 'm6 6 12 12'], x: ['M18 6 6 18', 'm6 6 12 12'],
plus: ['M5 12h14', 'M12 5v14'], plus: ['M5 12h14', 'M12 5v14'],
sharepoint: [
'M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242',
'M12 12v6',
'm-2.5-2.5 2.5 2.5 2.5-2.5',
],
}; };
</script> </script>
@@ -1,6 +1,6 @@
import { api } from './api'; import { api } from './api';
export interface UserResponse { export type UserResponse = {
id: string; id: string;
email: string; email: string;
displayName: string; displayName: string;
@@ -1,11 +1,11 @@
import { api } from './api'; import { api } from './api';
export interface ParagonTokenResponse { export type ParagonTokenResponse = {
paragonJwt: string; paragonJwt: string;
projectId: string; projectId: string;
} }
export interface IntegrationConfig { export type IntegrationConfig = {
id: string; id: string;
organizationId: string; organizationId: string;
connectionMode: string; connectionMode: string;
@@ -15,20 +15,20 @@ export interface IntegrationConfig {
updatedAt: string; updatedAt: string;
} }
export interface IntegrationConfigRequest { export type IntegrationConfigRequest = {
connectionMode: string; connectionMode: string;
sharePointSiteUrl: string | null; sharePointSiteUrl: string | null;
sharePointFolderPath: string | null; sharePointFolderPath: string | null;
} }
export interface CredentialResponse { export type CredentialResponse = {
id: string; id: string;
credentialId: string; credentialId: string;
integrationType: string; integrationType: string;
connectedAt: string; connectedAt: string;
} }
export interface CredentialRequest { export type CredentialRequest = {
credentialId: string; credentialId: string;
integrationType: string; integrationType: string;
} }
@@ -1,6 +1,6 @@
import { api } from './api'; import { api } from './api';
export interface StorageItem { export type StorageItem = {
id: string; id: string;
name: string; name: string;
isFolder: boolean; isFolder: boolean;
@@ -11,18 +11,35 @@ export interface StorageItem {
createdByUserId: string; createdByUserId: string;
createdByDisplayName: string; createdByDisplayName: string;
createdAt: string; createdAt: string;
isReadOnly: boolean;
isManagedSync: boolean;
} }
export interface CreateFolderRequest { export type CreateFolderRequest = {
name: string; name: string;
parentId: string | null; parentId: string | null;
} }
export interface DownloadResponse { export type DownloadResponse = {
sharePointUrl: string | null; sharePointUrl: string | null;
proxyUrl: string | null; proxyUrl: string | null;
} }
export type CreateSyncedFolderRequest = {
sharePointFolderId: string;
sharePointSiteId: string;
parentId?: string | null;
credentialId?: string;
}
export async function createSyncedFolder(req: CreateSyncedFolderRequest): Promise<StorageItem> {
return api<StorageItem>('/storage/synced-folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
}
export async function getItems(parentId?: string | null): Promise<StorageItem[]> { export async function getItems(parentId?: string | null): Promise<StorageItem[]> {
const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : ''; const params = parentId ? `?parentId=${encodeURIComponent(parentId)}` : '';
return api<StorageItem[]>(`/storage${params}`); return api<StorageItem[]>(`/storage${params}`);
@@ -129,7 +129,8 @@ select:focus {
transform: translateY(1px); transform: translateY(1px);
} }
.btn:disabled { .btn:disabled,
.btn.disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -9,8 +9,44 @@
deleteItem, deleteItem,
getDownloadUrls, getDownloadUrls,
type StorageItem, type StorageItem,
createSyncedFolder,
} from '../services/storage'; } from '../services/storage';
import { formatLocaleDate } from '../utils/utils'; import { formatLocaleDate } from '../utils/utils';
import { getParagonToken } from '../services/integration';
type SharePointIds = {
listId: string;
webId: string;
siteId: string;
listItemId: string;
listItemUniqueId: string;
};
type ParentReference = {
driveId: string;
sharepointIds: SharePointIds;
};
type SelectedFilePickerItem = {
id: string;
parentReference: ParentReference;
sharepointIds: SharePointIds;
};
type ParagonPickerInstance = {
init: () => Promise<void>;
open: () => void;
};
type ParagonSDKWithPicker = {
authenticate: (projectId: string, jwt: string) => Promise<void>;
ExternalFilePicker: new (
integration: string,
options: {
onFileSelect: (items: SelectedFilePickerItem[]) => Promise<void> | void;
},
) => ParagonPickerInstance;
};
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -18,13 +54,14 @@
const items = ref<StorageItem[]>([]); const items = ref<StorageItem[]>([]);
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const currentFolderId = ref<string | null>(null);
const breadcrumbs = ref<{ id: string | null; name: string }[]>([]); const breadcrumbs = ref<{ id: string | null; name: string }[]>([]);
const showNewFolder = ref(false); const showNewFolder = ref(false);
const newFolderName = ref(''); const newFolderName = ref('');
const currentFolder = ref<StorageItem | null>(null);
const folders = computed(() => items.value.filter((i) => i.isFolder)); const folders = computed(() => items.value.filter((i) => i.isFolder));
const files = computed(() => items.value.filter((i) => !i.isFolder)); const files = computed(() => items.value.filter((i) => !i.isFolder));
const isCurrentFolderReadOnly = computed(() => currentFolder.value?.isReadOnly ?? false);
const pathSegments = computed(() => { const pathSegments = computed(() => {
const raw = route.params.pathMatch; const raw = route.params.pathMatch;
@@ -51,6 +88,7 @@
try { try {
let parentId: string | null = null; let parentId: string | null = null;
const crumbs: { id: string | null; name: string }[] = []; const crumbs: { id: string | null; name: string }[] = [];
let resolvedFolder: StorageItem | null = null;
for (const name of segments) { for (const name of segments) {
const children = await getItems(parentId); const children = await getItems(parentId);
@@ -64,15 +102,17 @@
crumbs.push({ id: folder.id, name: folder.name }); crumbs.push({ id: folder.id, name: folder.name });
parentId = folder.id; parentId = folder.id;
resolvedFolder = folder;
} }
currentFolderId.value = parentId; currentFolder.value = resolvedFolder;
breadcrumbs.value = crumbs; breadcrumbs.value = crumbs;
items.value = await getItems(currentFolderId.value); items.value = await getItems(currentFolder.value?.id ?? null);
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to navigate'; error.value = e instanceof Error ? e.message : 'Failed to navigate';
items.value = []; items.value = [];
currentFolder.value = null;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -94,7 +134,7 @@
try { try {
await createFolder({ await createFolder({
name: newFolderName.value.trim(), name: newFolderName.value.trim(),
parentId: currentFolderId.value, parentId: currentFolder.value?.id ?? null,
}); });
newFolderName.value = ''; newFolderName.value = '';
@@ -115,7 +155,7 @@
} }
try { try {
await uploadFile(file, currentFolderId.value); await uploadFile(file, currentFolder.value?.id ?? null);
input.value = ''; input.value = '';
@@ -167,6 +207,39 @@
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`; return `${(bytes / 1048576).toFixed(1)} MB`;
} }
async function openSharePointFolderPicker() {
try {
const tokenResponse = await getParagonToken();
const { paragon } = await import('@useparagon/connect');
const sdk = paragon as unknown as ParagonSDKWithPicker;
await sdk.authenticate(tokenResponse.projectId, tokenResponse.paragonJwt);
const picker = new sdk.ExternalFilePicker('sharepoint', {
onFileSelect: async (selectedItems: SelectedFilePickerItem[]) => {
if (!selectedItems || selectedItems.length === 0) return;
const selectedItem = selectedItems[0];
const folderId = selectedItem.id;
const siteId = selectedItem.sharepointIds.siteId;
await createSyncedFolder({
sharePointFolderId: folderId,
sharePointSiteId: siteId,
parentId: currentFolder.value?.id ?? null,
});
await navigateToPath(pathSegments.value);
},
});
await picker.init();
picker.open();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to open SharePoint picker';
}
}
</script> </script>
<template> <template>
@@ -185,7 +258,7 @@
<nav class="breadcrumbs"> <nav class="breadcrumbs">
<button <button
class="crumb" class="crumb"
:class="{ active: currentFolderId === null }" :class="{ active: currentFolder === null }"
@click="navigateToBreadcrumb(0)" @click="navigateToBreadcrumb(0)"
> >
Root Root
@@ -208,6 +281,7 @@
<div class="toolbar"> <div class="toolbar">
<button <button
class="btn" class="btn"
:disabled="isCurrentFolderReadOnly"
@click="showNewFolder = !showNewFolder" @click="showNewFolder = !showNewFolder"
> >
<AppIcon <AppIcon
@@ -218,7 +292,10 @@
{{ showNewFolder ? 'Cancel' : 'New Folder' }} {{ showNewFolder ? 'Cancel' : 'New Folder' }}
</button> </button>
<label class="btn btn-primary upload-label"> <label
class="btn btn-primary upload-label"
:class="{ disabled: isCurrentFolderReadOnly }"
>
<AppIcon <AppIcon
name="upload" name="upload"
:size="14" :size="14"
@@ -227,9 +304,21 @@
<input <input
type="file" type="file"
hidden hidden
:disabled="isCurrentFolderReadOnly"
@change="handleUpload" @change="handleUpload"
/> />
</label> </label>
<button
class="btn btn-secondary"
@click="openSharePointFolderPicker"
>
<AppIcon
name="sharepoint"
:size="14"
/>
Sync Folder from SharePoint
</button>
</div> </div>
<div <div
@@ -289,6 +378,7 @@
<span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span> <span class="item-date">{{ formatLocaleDate(folder.createdAt) }}</span>
<span class="item-actions"> <span class="item-actions">
<button <button
v-if="!folder.isReadOnly || folder.isManagedSync"
class="btn-small btn-small-danger" class="btn-small btn-small-danger"
title="Delete" title="Delete"
@click="handleDelete(folder.id)" @click="handleDelete(folder.id)"
@@ -337,6 +427,7 @@
/> />
</button> </button>
<button <button
v-if="!file.isReadOnly"
class="btn-small btn-small-danger" class="btn-small btn-small-danger"
title="Delete" title="Delete"
@click="handleDelete(file.id)" @click="handleDelete(file.id)"