3 files changed
+241
-0
org.v
new file
+86
-0
@@ -0,0 +1,86 @@ |
|||
| 1 | + | // Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. |
|
| 2 | + | // Use of this source code is governed by a GPL license that can be found in the LICENSE file. |
|
| 3 | + | module main |
|
| 4 | + | ||
| 5 | + | import time |
|
| 6 | + | ||
| 7 | + | struct Org { |
|
| 8 | + | id int @[primary; sql: serial] |
|
| 9 | + | name string @[unique] |
|
| 10 | + | contact_email string |
|
| 11 | + | kind string |
|
| 12 | + | created_at time.Time |
|
| 13 | + | created_by int |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | struct OrgMember { |
|
| 17 | + | id int @[primary; sql: serial] |
|
| 18 | + | org_id int @[unique: 'org_member'] |
|
| 19 | + | user_id int @[unique: 'org_member'] |
|
| 20 | + | role string |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub fn (mut app App) add_org(name string, contact_email string, kind string, created_by int) !int { |
|
| 24 | + | new_org := Org{ |
|
| 25 | + | name: name |
|
| 26 | + | contact_email: contact_email |
|
| 27 | + | kind: kind |
|
| 28 | + | created_at: time.now() |
|
| 29 | + | created_by: created_by |
|
| 30 | + | } |
|
| 31 | + | sql app.db { |
|
| 32 | + | insert new_org into Org |
|
| 33 | + | }! |
|
| 34 | + | row := app.get_org_by_name(name) or { return error('failed to load newly created org') } |
|
| 35 | + | return row.id |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | pub fn (app App) get_org_by_name(name string) ?Org { |
|
| 39 | + | rows := sql app.db { |
|
| 40 | + | select from Org where name == name limit 1 |
|
| 41 | + | } or { [] } |
|
| 42 | + | if rows.len == 0 { |
|
| 43 | + | return none |
|
| 44 | + | } |
|
| 45 | + | return rows.first() |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | pub fn (app App) get_org_by_id(id int) ?Org { |
|
| 49 | + | rows := sql app.db { |
|
| 50 | + | select from Org where id == id limit 1 |
|
| 51 | + | } or { [] } |
|
| 52 | + | if rows.len == 0 { |
|
| 53 | + | return none |
|
| 54 | + | } |
|
| 55 | + | return rows.first() |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | pub fn (mut app App) add_org_member(org_id int, user_id int, role string) ! { |
|
| 59 | + | member := OrgMember{ |
|
| 60 | + | org_id: org_id |
|
| 61 | + | user_id: user_id |
|
| 62 | + | role: role |
|
| 63 | + | } |
|
| 64 | + | sql app.db { |
|
| 65 | + | insert member into OrgMember |
|
| 66 | + | }! |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | pub fn (app App) find_orgs_for_user(user_id int) []Org { |
|
| 70 | + | members := sql app.db { |
|
| 71 | + | select from OrgMember where user_id == user_id |
|
| 72 | + | } or { [] } |
|
| 73 | + | mut orgs := []Org{cap: members.len} |
|
| 74 | + | for m in members { |
|
| 75 | + | org := app.get_org_by_id(m.org_id) or { continue } |
|
| 76 | + | orgs << org |
|
| 77 | + | } |
|
| 78 | + | return orgs |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | pub fn (app App) is_org_member(org_id int, user_id int) bool { |
|
| 82 | + | count := sql app.db { |
|
| 83 | + | select count from OrgMember where org_id == org_id && user_id == user_id |
|
| 84 | + | } or { 0 } |
|
| 85 | + | return count > 0 |
|
| 86 | + | } |
|
org_routes.v
new file
+68
-0
@@ -0,0 +1,68 @@ |
|||
| 1 | + | // Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. |
|
| 2 | + | // Use of this source code is governed by a GPL license that can be found in the LICENSE file. |
|
| 3 | + | module main |
|
| 4 | + | ||
| 5 | + | import veb |
|
| 6 | + | import validation |
|
| 7 | + | ||
| 8 | + | @['/organizations/new'] |
|
| 9 | + | pub fn (mut app App) new_org(mut ctx Context) veb.Result { |
|
| 10 | + | if !ctx.logged_in { |
|
| 11 | + | return ctx.redirect_to_login() |
|
| 12 | + | } |
|
| 13 | + | return $veb.html('templates/new/org.html') |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | @['/organizations/new'; post] |
|
| 17 | + | pub fn (mut app App) handle_new_org(mut ctx Context) veb.Result { |
|
| 18 | + | if !ctx.logged_in { |
|
| 19 | + | return ctx.redirect_to_login() |
|
| 20 | + | } |
|
| 21 | + | org_name := ctx.form['org_name'] |
|
| 22 | + | contact_email := ctx.form['contact_email'] |
|
| 23 | + | org_kind := ctx.form['org_kind'] |
|
| 24 | + | accept_terms := ctx.form['accept_terms'] == '1' |
|
| 25 | + | ||
| 26 | + | if validation.is_string_empty(org_name) { |
|
| 27 | + | ctx.error('Organization name is required') |
|
| 28 | + | return app.new_org(mut ctx) |
|
| 29 | + | } |
|
| 30 | + | if org_name.len > max_username_len { |
|
| 31 | + | ctx.error('The organization name is too long (should be fewer than ${max_username_len} characters)') |
|
| 32 | + | return app.new_org(mut ctx) |
|
| 33 | + | } |
|
| 34 | + | if org_name.contains(' ') { |
|
| 35 | + | ctx.error('Organization name cannot contain spaces') |
|
| 36 | + | return app.new_org(mut ctx) |
|
| 37 | + | } |
|
| 38 | + | if validation.is_string_empty(contact_email) { |
|
| 39 | + | ctx.error('Contact email is required') |
|
| 40 | + | return app.new_org(mut ctx) |
|
| 41 | + | } |
|
| 42 | + | if org_kind != 'personal' && org_kind != 'business' { |
|
| 43 | + | ctx.error('Please select who this organization belongs to') |
|
| 44 | + | return app.new_org(mut ctx) |
|
| 45 | + | } |
|
| 46 | + | if !accept_terms { |
|
| 47 | + | ctx.error('You must accept the Terms of Service') |
|
| 48 | + | return app.new_org(mut ctx) |
|
| 49 | + | } |
|
| 50 | + | if _ := app.get_user_by_username(org_name) { |
|
| 51 | + | ctx.error('The name "${org_name}" is already taken') |
|
| 52 | + | return app.new_org(mut ctx) |
|
| 53 | + | } |
|
| 54 | + | if _ := app.get_org_by_name(org_name) { |
|
| 55 | + | ctx.error('The name "${org_name}" is already taken') |
|
| 56 | + | return app.new_org(mut ctx) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | org_id := app.add_org(org_name, contact_email, org_kind, ctx.user.id) or { |
|
| 60 | + | ctx.error('Could not create organization: ${err}') |
|
| 61 | + | return app.new_org(mut ctx) |
|
| 62 | + | } |
|
| 63 | + | app.add_org_member(org_id, ctx.user.id, 'admin') or { |
|
| 64 | + | ctx.error('Could not add you as the organization owner: ${err}') |
|
| 65 | + | return app.new_org(mut ctx) |
|
| 66 | + | } |
|
| 67 | + | return ctx.redirect('/new?owner=${org_name}') |
|
| 68 | + | } |
|
templates/new/org.html
new file
+87
-0
@@ -0,0 +1,87 @@ |
|||
| 1 | + | <!DOCTYPE html> |
|
| 2 | + | <html> |
|
| 3 | + | <head> |
|
| 4 | + | @include '../layout/head.html' |
|
| 5 | + | </head> |
|
| 6 | + | <body> |
|
| 7 | + | @include '../layout/header.html' |
|
| 8 | + | ||
| 9 | + | <div class="content"> |
|
| 10 | + | <div class="new-org"> |
|
| 11 | + | <div class="new-org__header"> |
|
| 12 | + | <p class="new-org__subtitle">%new_org_subtitle</p> |
|
| 13 | + | <h1>%new_org_title</h1> |
|
| 14 | + | </div> |
|
| 15 | + | ||
| 16 | + | @if ctx.form_error != '' |
|
| 17 | + | <div class='form-error'>@ctx.form_error</div> |
|
| 18 | + | @end |
|
| 19 | + | ||
| 20 | + | <form class="new-org__form" method='post' action='/organizations/new'> |
|
| 21 | + | <div class="new-org__field"> |
|
| 22 | + | <label class="new-org__label" for="org_name">%new_org_name <span class="new-org__req">*</span></label> |
|
| 23 | + | <input id="org_name" class="new-org__input" type='text' name='org_name' required autofocus maxlength=@max_username_len> |
|
| 24 | + | <p class="new-org__hint">%new_org_name_hint</p> |
|
| 25 | + | <p class="new-org__hint">%new_org_url_hint https://@app.config.hostname/<span class="new-org__url-slot" id="url_slot"></span></p> |
|
| 26 | + | </div> |
|
| 27 | + | ||
| 28 | + | <div class="new-org__field"> |
|
| 29 | + | <label class="new-org__label" for="contact_email">%new_org_contact_email <span class="new-org__req">*</span></label> |
|
| 30 | + | <input id="contact_email" class="new-org__input" type='email' name='contact_email' required> |
|
| 31 | + | </div> |
|
| 32 | + | ||
| 33 | + | <div class="new-org__field"> |
|
| 34 | + | <p class="new-org__label">%new_org_belongs_to</p> |
|
| 35 | + | <label class="new-org__option"> |
|
| 36 | + | <input type="radio" name="org_kind" value="personal" required> |
|
| 37 | + | <span class="new-org__option-body"> |
|
| 38 | + | <span class="new-org__option-title">%new_org_personal</span> |
|
| 39 | + | <span class="new-org__option-desc"> |
|
| 40 | + | %new_org_personal_example @ctx.user.username |
|
| 41 | + | @if ctx.user.full_name != '' |
|
| 42 | + | (@ctx.user.full_name) |
|
| 43 | + | @end |
|
| 44 | + | </span> |
|
| 45 | + | </span> |
|
| 46 | + | </label> |
|
| 47 | + | <label class="new-org__option"> |
|
| 48 | + | <input type="radio" name="org_kind" value="business" required> |
|
| 49 | + | <span class="new-org__option-body"> |
|
| 50 | + | <span class="new-org__option-title">%new_org_business</span> |
|
| 51 | + | <span class="new-org__option-desc">%new_org_business_example</span> |
|
| 52 | + | </span> |
|
| 53 | + | </label> |
|
| 54 | + | </div> |
|
| 55 | + | ||
| 56 | + | <div class="new-org__field new-org__field--inline"> |
|
| 57 | + | <label class="new-org__checkbox"> |
|
| 58 | + | <input type="checkbox" id="accept_terms" name="accept_terms" value="1" required> |
|
| 59 | + | <span>%new_org_accept_terms</span> |
|
| 60 | + | </label> |
|
| 61 | + | </div> |
|
| 62 | + | ||
| 63 | + | <div class="new-org__actions"> |
|
| 64 | + | <input id="new_org_submit" type='submit' class='new-org__submit' value='%new_org_next' disabled> |
|
| 65 | + | </div> |
|
| 66 | + | </form> |
|
| 67 | + | </div> |
|
| 68 | + | </div> |
|
| 69 | + | ||
| 70 | + | <script> |
|
| 71 | + | (function() { |
|
| 72 | + | var nameInput = document.getElementById('org_name'); |
|
| 73 | + | var slot = document.getElementById('url_slot'); |
|
| 74 | + | var terms = document.getElementById('accept_terms'); |
|
| 75 | + | var submit = document.getElementById('new_org_submit'); |
|
| 76 | + | function syncUrl() { slot.textContent = nameInput.value; } |
|
| 77 | + | function syncSubmit() { submit.disabled = !terms.checked; } |
|
| 78 | + | nameInput.addEventListener('input', syncUrl); |
|
| 79 | + | terms.addEventListener('change', syncSubmit); |
|
| 80 | + | syncUrl(); |
|
| 81 | + | syncSubmit(); |
|
| 82 | + | })(); |
|
| 83 | + | </script> |
|
| 84 | + | ||
| 85 | + | @include '../layout/footer.html' |
|
| 86 | + | </body> |
|
| 87 | + | </html> |
|