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, name: ?[]const u8 = null,
lifespan: ?DateTime.Duration = null, lifespan: ?DateTime.Duration = null,
max_uses: ?u16 = null, max_uses: ?usize = null,
// admin only options // admin only options
kind: Kind = .user, kind: Kind = .user,
@ -209,11 +209,21 @@ pub const FileResult = struct {
data: []const u8, data: []const u8,
}; };
pub const ValidInvite = struct { pub const InviteResponse = struct {
code: []const u8, code: []const u8,
kind: services.invites.Kind, kind: services.invites.Kind,
name: []const u8, name: []const u8,
creator: UserResponse, 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 { pub fn isAdminSetup(db: sql.Db) !bool {
@ -356,7 +366,7 @@ fn ApiConn(comptime DbConn: type) type {
return error.TokenRequired; 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()) { if (!self.isAdmin()) {
return error.PermissionDenied; return error.PermissionDenied;
} }
@ -366,7 +376,7 @@ fn ApiConn(comptime DbConn: type) type {
const community_id = try services.communities.create( const community_id = try services.communities.create(
tx, tx,
origin, origin,
.{}, .{ .name = name },
self.allocator, self.allocator,
); );
@ -384,7 +394,7 @@ fn ApiConn(comptime DbConn: type) type {
return community; 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 // Only logged in users can make invites
const user_id = self.user_id orelse return error.TokenRequired; const user_id = self.user_id orelse return error.TokenRequired;
@ -405,7 +415,39 @@ fn ApiConn(comptime DbConn: type) type {
.kind = options.kind, .kind = options.kind,
}, self.allocator); }, 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 { 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); 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( const invite = services.invites.getByCode(
self.db, self.db,
code, code,
@ -815,16 +857,33 @@ fn ApiConn(comptime DbConn: type) type {
if (!Uuid.eql(invite.community_id, self.community.id)) return error.InvalidInvite; if (!Uuid.eql(invite.community_id, self.community.id)) return error.InvalidInvite;
if (!isInviteValid(invite)) 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) { const creator = self.getUserUnchecked(self.db, invite.created_by) catch |err| switch (err) {
error.NotFound => return error.Unexpected, error.NotFound => return error.Unexpected,
else => return error.DatabaseFailure, else => return error.DatabaseFailure,
}; };
return ValidInvite{ return InviteResponse{
.code = invite.code, .code = invite.code,
.name = invite.name, .name = invite.name,
.kind = invite.kind, .kind = invite.kind,
.creator = creator, .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; pub const jsonStringify = util.jsonSerializeEnumAsString;
}; };
const InviteCount = u16; const InviteCount = usize;
pub const Invite = struct { pub const Invite = struct {
id: Uuid, id: Uuid,
created_by: Uuid, // User ID created_by: Uuid, // User ID
community_id: ?Uuid, community_id: Uuid,
name: []const u8, name: []const u8,
code: []const u8, code: []const u8,
@ -127,8 +127,8 @@ fn doGetQuery(
); );
return db.queryRow(Invite, query, query_args, alloc) catch |err| switch (err) { return db.queryRow(Invite, query, query_args, alloc) catch |err| switch (err) {
error.NoRows => error.NotFound, error.NoRows => return error.NotFound,
else => error.DatabaseFailure, else => return error.DatabaseFailure,
}; };
} }

View File

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

View File

@ -14,6 +14,8 @@ pub const routes = .{
controllers.apiEndpoint(signup.page), controllers.apiEndpoint(signup.page),
controllers.apiEndpoint(signup.with_invite), controllers.apiEndpoint(signup.with_invite),
controllers.apiEndpoint(signup.submit), controllers.apiEndpoint(signup.submit),
controllers.apiEndpoint(cluster.communities.create.page),
controllers.apiEndpoint(cluster.communities.create.submit),
}; };
const static = struct { 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 { 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 { 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)) { return switch (c.sqlite3_column_type(stmt, idx)) {
c.SQLITE_INTEGER => getColumnInt(stmt, T, idx), c.SQLITE_INTEGER => try getColumnInt(stmt, Eff, idx),
c.SQLITE_FLOAT => getColumnFloat(stmt, T, idx), c.SQLITE_FLOAT => try getColumnFloat(stmt, Eff, idx),
c.SQLITE_TEXT => getColumnText(stmt, T, idx, alloc), c.SQLITE_TEXT => try getColumnText(stmt, Eff, idx, alloc),
c.SQLITE_NULL => { c.SQLITE_NULL => {
if (T == DateTime) { if (T == DateTime) {
std.log.warn("SQLite: Treating NULL as DateTime epoch", .{}); std.log.warn("SQLite: Treating NULL as DateTime epoch", .{});