Add community creation page

This commit is contained in:
jaina heartles 2022-12-11 19:52:11 -08:00
parent b781b34029
commit e617406872
7 changed files with 144 additions and 16 deletions

View file

@ -34,7 +34,7 @@ pub const InviteOptions = struct {
name: ?[]const u8 = null,
lifespan: ?DateTime.Duration = null,
max_uses: ?u16 = null,
max_uses: ?usize = null,
// admin only options
kind: Kind = .user,
@ -209,11 +209,21 @@ pub const FileResult = struct {
data: []const u8,
};
pub const ValidInvite = struct {
pub const InviteResponse = struct {
code: []const u8,
kind: services.invites.Kind,
name: []const u8,
creator: UserResponse,
url: []const u8,
community_id: Uuid,
created_at: DateTime,
expires_at: ?DateTime,
times_used: usize,
max_uses: ?usize,
};
pub fn isAdminSetup(db: sql.Db) !bool {
@ -356,7 +366,7 @@ fn ApiConn(comptime DbConn: type) type {
return error.TokenRequired;
}
pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community {
pub fn createCommunity(self: *Self, origin: []const u8, name: ?[]const u8) !services.communities.Community {
if (!self.isAdmin()) {
return error.PermissionDenied;
}
@ -366,7 +376,7 @@ fn ApiConn(comptime DbConn: type) type {
const community_id = try services.communities.create(
tx,
origin,
.{},
.{ .name = name },
self.allocator,
);
@ -384,7 +394,7 @@ fn ApiConn(comptime DbConn: type) type {
return community;
}
pub fn createInvite(self: *Self, options: InviteOptions) !services.invites.Invite {
pub fn createInvite(self: *Self, options: InviteOptions) !InviteResponse {
// Only logged in users can make invites
const user_id = self.user_id orelse return error.TokenRequired;
@ -405,7 +415,39 @@ fn ApiConn(comptime DbConn: type) type {
.kind = options.kind,
}, self.allocator);
return try services.invites.get(self.db, invite_id, self.allocator);
const invite = try services.invites.get(self.db, invite_id, self.allocator);
errdefer util.deepFree(self.allocator, invite);
const url = if (options.to_community) |cid| blk: {
const community = try services.communities.get(self.db, cid, self.allocator);
defer util.deepFree(self.allocator, community);
break :blk try std.fmt.allocPrint(
self.allocator,
"{s}://{s}/invite/{s}",
.{ @tagName(community.scheme), community.host, invite.code },
);
} else try std.fmt.allocPrint(
self.allocator,
"{s}://{s}/invite/{s}",
.{ @tagName(self.community.scheme), self.community.host, invite.code },
);
errdefer util.deepFree(self.allocator, url);
const user = try self.getUserUnchecked(self.db, user_id);
return InviteResponse{
.code = invite.code,
.kind = invite.kind,
.name = invite.name,
.creator = user,
.url = url,
.community_id = invite.community_id,
.created_at = invite.created_at,
.expires_at = invite.expires_at,
.times_used = invite.times_used,
.max_uses = invite.max_uses,
};
}
fn isInviteValid(invite: services.invites.Invite) bool {
@ -800,7 +842,7 @@ fn ApiConn(comptime DbConn: type) type {
try services.actors.updateProfile(self.db, id, data, self.allocator);
}
pub fn validateInvite(self: *Self, code: []const u8) !ValidInvite {
pub fn validateInvite(self: *Self, code: []const u8) !InviteResponse {
const invite = services.invites.getByCode(
self.db,
code,
@ -815,16 +857,33 @@ fn ApiConn(comptime DbConn: type) type {
if (!Uuid.eql(invite.community_id, self.community.id)) return error.InvalidInvite;
if (!isInviteValid(invite)) return error.InvalidInvite;
const url = try std.fmt.allocPrint(
self.allocator,
"{s}://{s}/invite/{s}",
.{ @tagName(self.community.scheme), self.community.host, invite.code },
);
errdefer util.deepFree(self.allocator, url);
const creator = self.getUserUnchecked(self.db, invite.created_by) catch |err| switch (err) {
error.NotFound => return error.Unexpected,
else => return error.DatabaseFailure,
};
return ValidInvite{
return InviteResponse{
.code = invite.code,
.name = invite.name,
.kind = invite.kind,
.creator = creator,
.url = url,
.community_id = invite.community_id,
.created_at = invite.created_at,
.expires_at = invite.expires_at,
.times_used = invite.times_used,
.max_uses = invite.max_uses,
};
}
};

View file

@ -20,12 +20,12 @@ pub const Kind = enum {
pub const jsonStringify = util.jsonSerializeEnumAsString;
};
const InviteCount = u16;
const InviteCount = usize;
pub const Invite = struct {
id: Uuid,
created_by: Uuid, // User ID
community_id: ?Uuid,
community_id: Uuid,
name: []const u8,
code: []const u8,
@ -127,8 +127,8 @@ fn doGetQuery(
);
return db.queryRow(Invite, query, query_args, alloc) catch |err| switch (err) {
error.NoRows => error.NotFound,
else => error.DatabaseFailure,
error.NoRows => return error.NotFound,
else => return error.DatabaseFailure,
};
}

View file

@ -9,10 +9,11 @@ pub const create = struct {
pub const Body = struct {
origin: []const u8,
name: ?[]const u8 = null,
};
pub fn handler(req: anytype, res: anytype, srv: anytype) !void {
const invite = try srv.createCommunity(req.body.origin);
const invite = try srv.createCommunity(req.body.origin, req.body.name);
try res.json(.created, invite);
}

View file

@ -14,6 +14,8 @@ pub const routes = .{
controllers.apiEndpoint(signup.page),
controllers.apiEndpoint(signup.with_invite),
controllers.apiEndpoint(signup.submit),
controllers.apiEndpoint(cluster.communities.create.page),
controllers.apiEndpoint(cluster.communities.create.submit),
};
const static = struct {
@ -221,6 +223,46 @@ const cluster = struct {
});
}
};
const communities = struct {
const create = struct {
const tmpl = @embedFile("./web/cluster/community-create.tmpl.html");
const success_tmpl = @embedFile("./web/cluster/community-create-success.tmpl.html");
const page = struct {
pub const path = "/cluster/communities/create";
pub const method = .GET;
pub fn handler(_: anytype, res: anytype, srv: anytype) !void {
try res.template(.ok, srv, tmpl, .{});
}
};
const submit = struct {
pub const path = "/cluster/communities/create";
pub const method = .POST;
pub const Body = struct {
origin: []const u8,
name: ?[]const u8,
};
pub fn handler(req: anytype, res: anytype, srv: anytype) !void {
const community = try srv.createCommunity(req.body.origin, req.body.name);
defer util.deepFree(srv.allocator, community);
const invite = try srv.createInvite(.{
.max_uses = 1,
.kind = .community_owner,
.to_community = community.id,
});
defer util.deepFree(srv.allocator, invite);
try res.template(.ok, srv, success_tmpl, .{ .community = community, .invite = invite });
}
};
};
};
};
const media = struct {

View file

@ -0,0 +1,9 @@
<h2>
Community {.community.name} created
</h2>
<div>
Send the following invite to the community owner for them
to complete setup.
<div>{.invite.url}</div>
</div>

View file

@ -0,0 +1,16 @@
<form action="/cluster/communities/create" method="post">
<h2>Create Local Community</h2>
<label>
<div>Origin</div>
<div class="textinput">
<input type="url" name="origin" placeholder="https://example.com" />
</div>
</label>
<label>
<div>Name</div>
<div class="textinput">
<input type="text" name="name" />
</div>
</label>
<button type="submit">Submit</button>
</form>

View file

@ -308,10 +308,11 @@ pub const Row = struct {
};
fn getColumn(stmt: *c.sqlite3_stmt, comptime T: type, idx: u15, alloc: ?Allocator) common.GetError!T {
const Eff = if (comptime std.meta.trait.is(.Optional)(T)) std.meta.Child(T) else T;
return switch (c.sqlite3_column_type(stmt, idx)) {
c.SQLITE_INTEGER => getColumnInt(stmt, T, idx),
c.SQLITE_FLOAT => getColumnFloat(stmt, T, idx),
c.SQLITE_TEXT => getColumnText(stmt, T, idx, alloc),
c.SQLITE_INTEGER => try getColumnInt(stmt, Eff, idx),
c.SQLITE_FLOAT => try getColumnFloat(stmt, Eff, idx),
c.SQLITE_TEXT => try getColumnText(stmt, Eff, idx, alloc),
c.SQLITE_NULL => {
if (T == DateTime) {
std.log.warn("SQLite: Treating NULL as DateTime epoch", .{});