diff --git a/resetdb.sh b/resetdb.sh new file mode 100644 index 00000000..e628e088 --- /dev/null +++ b/resetdb.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +THIS_PATH=$(pwd) +BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' + +cd $BETA_PATH +docker-compose down -v +docker-compose up -d postgres +sleep 3 +./scripts/db_import -d -n hmn_two -c + +cd $THIS_PATH +go run src/main.go migrate 2021-03-10T05:16:21Z + +cd $BETA_PATH +./scripts/db_import -d -n hmn_two -a ./dbdumps/hmn_pg_dump_2020-11-10 diff --git a/src/db/db.go b/src/db/db.go index 0f3404e2..3407b1f4 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -5,7 +5,6 @@ import ( "errors" "reflect" "strings" - "time" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/oops" @@ -94,6 +93,10 @@ func (it *StructQueryIterator) ToSlice() []interface{} { for { row, ok := it.Next() if !ok { + err := it.rows.Err() + if err != nil { + panic(oops.New(err, "error while iterating through db results")) + } break } result = append(result, row) @@ -119,14 +122,11 @@ func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value return val } -func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (StructQueryIterator, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - +func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) { destType := reflect.TypeOf(destExample) columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") if err != nil { - return StructQueryIterator{}, oops.New(err, "failed to generate column names") + return nil, oops.New(err, "failed to generate column names") } columnNamesString := strings.Join(columnNames, ", ") @@ -137,10 +137,10 @@ func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, que if errors.Is(err, context.DeadlineExceeded) { panic("query exceeded its deadline") } - return StructQueryIterator{}, err + return nil, err } - return StructQueryIterator{ + return &StructQueryIterator{ fieldPaths: fieldPaths, rows: rows, destType: destType, diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index d2be1e78..166c2680 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -4,6 +4,7 @@ import ( "net/url" "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/oops" ) const StaticPath = "/public" @@ -14,12 +15,35 @@ type Q struct { Value string } -func Url(path string, query []Q) string { - result := config.Config.BaseUrl + "/" + trim(path) - if q := encodeQuery(query); q != "" { - result += "?" + q +var baseUrlParsed url.URL + +func init() { + parsed, err := url.Parse(config.Config.BaseUrl) + if err != nil { + panic(oops.New(err, "could not parse base URL")) } - return result + + baseUrlParsed = *parsed +} + +func Url(path string, query []Q) string { + return ProjectUrl(path, query, "") +} + +func ProjectUrl(path string, query []Q, subdomain string) string { + host := baseUrlParsed.Host + if len(subdomain) > 0 { + host = subdomain + "." + host + } + + url := url.URL{ + Scheme: baseUrlParsed.Scheme, + Host: host, + Path: trim(path), + RawQuery: encodeQuery(query), + } + + return url.String() } func StaticUrl(path string, query []Q) string { diff --git a/src/migration/migration.go b/src/migration/migration.go index 252273cf..af1af985 100644 --- a/src/migration/migration.go +++ b/src/migration/migration.go @@ -193,8 +193,8 @@ func Migrate(targetVersion types.MigrationVersion) { // roll forward for i := currentIndex + 1; i <= targetIndex; i++ { version := allVersions[i] - fmt.Printf("Applying migration %v\n", version) migration := migrations.All[version] + fmt.Printf("Applying migration %v (%v)\n", version, migration.Name()) tx, err := conn.Begin(context.Background()) if err != nil { diff --git a/src/migration/migrations/2021-04-09T000000Z_DeleteOrphanedLinks.go b/src/migration/migrations/2021-04-09T000000Z_DeleteOrphanedLinks.go new file mode 100644 index 00000000..ec58bd4a --- /dev/null +++ b/src/migration/migrations/2021-04-09T000000Z_DeleteOrphanedLinks.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(DeleteOrphanedLinks{}) +} + +type DeleteOrphanedLinks struct{} + +func (m DeleteOrphanedLinks) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 9, 0, 0, 0, 0, time.UTC)) +} + +func (m DeleteOrphanedLinks) Name() string { + return "DeleteOrphanedLinks" +} + +func (m DeleteOrphanedLinks) Description() string { + return "Delete links with no member or project" +} + +func (m DeleteOrphanedLinks) Up(tx pgx.Tx) error { + // Delete orphaned links (no member or project) + _, err := tx.Exec(context.Background(), ` + DELETE FROM handmade_links + WHERE + id IN ( + SELECT links.id + FROM + handmade_links AS links + LEFT JOIN handmade_memberextended_links AS mlinks ON mlinks.links_id = links.id + LEFT JOIN handmade_project_links AS plinks ON plinks.links_id = links.id + WHERE + mlinks.id IS NULL + AND plinks.id IS NULL + ) + `) + if err != nil { + return oops.New(err, "failed to delete orphaned links") + } + + return nil +} + +func (m DeleteOrphanedLinks) Down(tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-04-10T013044Z_CopyMemberExtendedData.go b/src/migration/migrations/2021-04-10T013044Z_CopyMemberExtendedData.go new file mode 100644 index 00000000..45b5f23d --- /dev/null +++ b/src/migration/migrations/2021-04-10T013044Z_CopyMemberExtendedData.go @@ -0,0 +1,107 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + // registerMigration(CopyMemberExtendedData{}) +} + +type CopyMemberExtendedData struct{} + +func (m CopyMemberExtendedData) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 10, 1, 30, 44, 0, time.UTC)) +} + +func (m CopyMemberExtendedData) Name() string { + return "CopyMemberExtendedData" +} + +func (m CopyMemberExtendedData) Description() string { + return "Copy MemberExtended data into Member" +} + +func (m CopyMemberExtendedData) Up(tx pgx.Tx) error { + // Add columns to member table + _, err := tx.Exec(context.Background(), ` + ALTER TABLE handmade_member + ADD COLUMN bio TEXT NOT NULL DEFAULT '', + ADD COLUMN showemail BOOLEAN NOT NULL DEFAULT FALSE + `) + if err != nil { + return oops.New(err, "failed to add columns to member table") + } + + // Copy data to members from memberextended + _, err = tx.Exec(context.Background(), ` + UPDATE handmade_member + SET (bio, showemail) = ( + SELECT COALESCE(bio, ''), showemail + FROM handmade_memberextended + WHERE handmade_memberextended.id = handmade_member.extended_id + ); + `) + if err != nil { + return oops.New(err, "failed to copy data from the memberextended table") + } + + // Directly associate links with members + _, err = tx.Exec(context.Background(), ` + ALTER TABLE handmade_links + ADD COLUMN member_id INTEGER REFERENCES handmade_member, + ADD COLUMN project_id INTEGER REFERENCES handmade_project, + ADD CONSTRAINT exactly_one_foreign_key CHECK ( + ( + CASE WHEN member_id IS NULL THEN 0 ELSE 1 END + + CASE WHEN project_id IS NULL THEN 0 ELSE 1 END + ) = 1 + ); + + UPDATE handmade_links + SET (member_id) = ( + SELECT mem.user_id + FROM + handmade_memberextended_links AS mlinks + JOIN handmade_memberextended AS memext ON memext.id = mlinks.memberextended_id + JOIN handmade_member AS mem ON mem.extended_id = memext.id + WHERE + mlinks.links_id = handmade_links.id + ); + + UPDATE handmade_links + SET (project_id) = ( + SELECT proj.id + FROM + handmade_project_links AS plinks + JOIN handmade_project AS proj ON proj.id = plinks.project_id + WHERE + plinks.links_id = handmade_links.id + ); + `) + if err != nil { + return oops.New(err, "failed to associate links with members") + } + + return nil +} + +func (m CopyMemberExtendedData) Down(tx pgx.Tx) error { + // _, err := tx.Exec(context.Background(), ` + // ALTER TABLE handmade_member + // DROP COLUMN bio, + // DROP COLUMN showemail + // `) + // if err != nil { + // return oops.New(err, "failed to drop columns from member table") + // } + // + // return nil + + panic("you do not want to do this") +} diff --git a/src/migration/migrations/2021-04-10T015339Z_DropMemberExtendedTable.go b/src/migration/migrations/2021-04-10T015339Z_DropMemberExtendedTable.go new file mode 100644 index 00000000..83ef0166 --- /dev/null +++ b/src/migration/migrations/2021-04-10T015339Z_DropMemberExtendedTable.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v4" +) + +func init() { + // TODO: Delete this migration + // registerMigration(DropMemberExtendedTable{}) +} + +type DropMemberExtendedTable struct{} + +func (m DropMemberExtendedTable) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 10, 1, 53, 39, 0, time.UTC)) +} + +func (m DropMemberExtendedTable) Name() string { + return "DropMemberExtendedTable" +} + +func (m DropMemberExtendedTable) Description() string { + return "Remove the MemberExtended record outright" +} + +func (m DropMemberExtendedTable) Up(tx pgx.Tx) error { + _, err := tx.Exec(context.Background(), ` + ALTER TABLE handmade_member + DROP COLUMN extended_id; + + DROP TABLE handmade_memberextended_links; + DROP TABLE handmade_memberextended; + `) + return err +} + +func (m DropMemberExtendedTable) Down(tx pgx.Tx) error { + panic("you do not want to do this") +} diff --git a/src/migration/migrations/2021-04-11T195747Z_RemoveMemberAndExtended.go b/src/migration/migrations/2021-04-11T195747Z_RemoveMemberAndExtended.go new file mode 100644 index 00000000..928172fd --- /dev/null +++ b/src/migration/migrations/2021-04-11T195747Z_RemoveMemberAndExtended.go @@ -0,0 +1,79 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +/* +Phase 1. Migrate the schemas for all the tables that will stick around through this +whole process. +*/ + +func init() { + registerMigration(RemoveMemberAndExtended{}) +} + +type RemoveMemberAndExtended struct{} + +func (m RemoveMemberAndExtended) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 11, 19, 57, 47, 0, time.UTC)) +} + +func (m RemoveMemberAndExtended) Name() string { + return "RemoveMemberAndExtended" +} + +func (m RemoveMemberAndExtended) Description() string { + return "Remove the member and member extended records, collapsing their data into users" +} + +func (m RemoveMemberAndExtended) Up(tx pgx.Tx) error { + // Creates a column that will eventually be a foreign key to auth_user. + createUserColumn := func(ctx context.Context, tx pgx.Tx, table string, before string, notNull bool) { + nullConstraint := "" + if notNull { + nullConstraint = "NOT NULL" + } + + _, err := tx.Exec(ctx, ` + ALTER TABLE `+table+` + ADD plz_rename INT `+nullConstraint+` DEFAULT 99999; + UPDATE `+table+` SET plz_rename = `+before+`; + `) + if err != nil { + panic(oops.New(err, "failed to update table %s to point at users instead of members", table)) + } + } + + /* + Models referencing handmade_member: + - CommunicationChoice + - CommunicationSubCategory + - CommunicationSubThread + - Discord + - CategoryLastReadInfo + - ThreadLastReadInfo + - PostTextVersion + - Post + - handmade_member_projects + */ + createUserColumn(context.Background(), tx, "handmade_communicationchoice", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_communicationsubcategory", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_communicationsubthread", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_discord", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_categorylastreadinfo", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_threadlastreadinfo", "member_id", true) + createUserColumn(context.Background(), tx, "handmade_posttextversion", "editor_id", false) + createUserColumn(context.Background(), tx, "handmade_post", "author_id", false) + + return nil +} + +func (m RemoveMemberAndExtended) Down(tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-04-11T210958Z_RemoveMemberAndExtended2.go b/src/migration/migrations/2021-04-11T210958Z_RemoveMemberAndExtended2.go new file mode 100644 index 00000000..9ee77558 --- /dev/null +++ b/src/migration/migrations/2021-04-11T210958Z_RemoveMemberAndExtended2.go @@ -0,0 +1,65 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +/* +Phase 2. Clean up the schema, adding constraints and dropping tables. Do not do any row updates +or deletes, because that causes trigger events, which make me sad. +*/ + +func init() { + registerMigration(RemoveMemberAndExtended2{}) +} + +type RemoveMemberAndExtended2 struct{} + +func (m RemoveMemberAndExtended2) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 4, 11, 21, 9, 58, 0, time.UTC)) +} + +func (m RemoveMemberAndExtended2) Name() string { + return "RemoveMemberAndExtended2" +} + +func (m RemoveMemberAndExtended2) Description() string { + return "Phase 2 of the above" +} + +func (m RemoveMemberAndExtended2) Up(tx pgx.Tx) error { + dropOldColumn := func(ctx context.Context, tx pgx.Tx, table string, before, after string, onDelete string) { + _, err := tx.Exec(ctx, ` + ALTER TABLE `+table+` + DROP `+before+`; + ALTER TABLE `+table+` + RENAME plz_rename TO `+after+`; + ALTER TABLE `+table+` + ADD FOREIGN KEY (`+after+`) REFERENCES auth_user ON DELETE `+onDelete+`, + ALTER `+after+` DROP DEFAULT; + `) + if err != nil { + panic(oops.New(err, "failed to update table %s to point at users instead of members", table)) + } + } + + dropOldColumn(context.Background(), tx, "handmade_communicationchoice", "member_id", "user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_communicationsubcategory", "member_id", "user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_communicationsubthread", "member_id", "user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_discord", "member_id", "hmn_user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_categorylastreadinfo", "member_id", "user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_threadlastreadinfo", "member_id", "user_id", "CASCADE") + dropOldColumn(context.Background(), tx, "handmade_posttextversion", "editor_id", "editor_id", "SET NULL") + dropOldColumn(context.Background(), tx, "handmade_post", "author_id", "author_id", "SET NULL") + + return nil +} + +func (m RemoveMemberAndExtended2) Down(tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/member.go b/src/models/member.go new file mode 100644 index 00000000..f41cb266 --- /dev/null +++ b/src/models/member.go @@ -0,0 +1,22 @@ +package models + +type Member struct { + UserID int + + Name *string `db:"name"` // TODO: Migrate to not null + Bio *string `db:"bio"` + Blurb *string `db:"blurb"` + Signature *string `db:"signature"` + Avatar *string `db:"avatar"` // TODO: Image field stuff? + + DarkTheme bool `db:"darktheme"` + Timezone string `db:"timezone"` + ProfileColor1 string `db:"color_1"` + ProfileColor2 string `db:"color_2"` + + ShowEmail bool `db:"showemail"` + CanEditLibrary bool `db:"edit_library"` + + DiscordSaveShowcase bool `db:"discord_save_showcase"` + DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` +} diff --git a/src/models/project.go b/src/models/project.go index f84c0517..f6d4a6f8 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -1,6 +1,9 @@ package models -import "reflect" +import ( + "reflect" + "time" +) const HMNProjectID = 1 @@ -16,6 +19,8 @@ type Project struct { Color1 string `db:"color_1"` Color2 string `db:"color_2"` + + AllLastUpdated time.Time `db:"all_last_updated"` } func (p *Project) IsHMN() bool { diff --git a/src/templates/mapping.go b/src/templates/mapping.go new file mode 100644 index 00000000..978dd0dc --- /dev/null +++ b/src/templates/mapping.go @@ -0,0 +1,59 @@ +package templates + +import "git.handmade.network/hmn/hmn/src/models" + +func MemberToTemplate(m *models.Member) Member { + return Member{ + Name: maybeString(m.Name), + Blurb: maybeString(m.Blurb), + Signature: maybeString(m.Signature), + + DarkTheme: m.DarkTheme, + Timezone: m.Timezone, + ProfileColor1: m.ProfileColor1, + ProfileColor2: m.ProfileColor2, + + CanEditLibrary: m.CanEditLibrary, + DiscordSaveShowcase: m.DiscordSaveShowcase, + DiscordDeleteSnippetOnMessageDelete: m.DiscordDeleteSnippetOnMessageDelete, + } +} + +func PostToTemplate(p *models.Post) Post { + return Post{ + Preview: p.Preview, + ReadOnly: p.ReadOnly, + } +} + +func ProjectToTemplate(p *models.Project) Project { + return Project{ + Name: maybeString(p.Name), + Subdomain: maybeString(p.Slug), + Color1: p.Color1, + Color2: p.Color2, + + IsHMN: p.IsHMN(), + + HasBlog: true, // TODO: Check flag sets or whatever + HasForum: true, + HasWiki: true, + HasLibrary: true, + } +} + +func UserToTemplate(u *models.User) User { + return User{ + Username: u.Username, + Email: u.Email, + IsSuperuser: u.IsSuperuser, + IsStaff: u.IsStaff, + } +} + +func maybeString(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 83677be1..fb111f5d 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -9,9 +9,9 @@ Logout {{ else }} Register - Log in + Log in