~netlandish/links-dev

links: Adding audit log views for the following: v1 APPLIED

Peter Sanchez: 1
 Adding audit log views for the following:

 13 files changed, 292 insertions(+), 6 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/114/mbox | git am -3
Learn more about email & git

[PATCH links] Adding audit log views for the following: Export this patch

- Personal account level
- Organization level
- Link listing level

Changelog-added: Handlers to view audit logs at various levels.
---
 accounts/routes.go                            |  14 +++
 api/graph/schema.resolvers.go                 |  57 +++++++++-
 cmd/migrations.go                             |   7 ++
 core/import.go                                |   2 +
 core/routes.go                                |  24 ++++
 helpers.go                                    | 103 ++++++++++++++++++
 list/routes.go                                |  29 +++++
 .../0006_update_auditlog_metadata.down.sql    |   2 +
 .../0006_update_auditlog_metadata.up.sql      |  10 ++
 templates/auditlog.html                       |  47 ++++++++
 templates/listing_list.html                   |   1 +
 templates/org_list.html                       |   1 +
 templates/settings.html                       |   1 +
 13 files changed, 292 insertions(+), 6 deletions(-)
 create mode 100644 migrations/0006_update_auditlog_metadata.down.sql
 create mode 100644 migrations/0006_update_auditlog_metadata.up.sql
 create mode 100644 templates/auditlog.html

diff --git a/accounts/routes.go b/accounts/routes.go
index d9350ce..9bb2beb 100644
--- a/accounts/routes.go
+++ b/accounts/routes.go
@@ -57,6 +57,7 @@ func (s *Service) RegisterRoutes() {
	gservice.RegisterRoutes()
	s.Group.Use(auth.AuthRequired())
	s.Group.GET("/settings", s.Settings).Name = s.RouteName("settings")
	s.Group.GET("/settings/log", s.UserLog).Name = s.RouteName("settings_log")
	s.Group.GET("/profile", s.EditProfile).Name = s.RouteName("profile_edit")
	s.Group.POST("/profile", s.EditProfile).Name = s.RouteName("profile_edit_post")
}
@@ -229,6 +230,7 @@ func (s *Service) Settings(c echo.Context) error {
	pd.Data["edit_profile"] = lt.Translate("Edit profile")
	pd.Data["manage"] = lt.Translate("Manage Your Organizations")
	pd.Data["upgrade"] = lt.Translate("Upgrade Org")
	pd.Data["logs"] = lt.Translate("View Audit Logs")

	user := gctx.User.(*models.User)
	langTrans := map[string]string{
@@ -278,6 +280,18 @@ func (s *Service) Settings(c echo.Context) error {
	return s.Render(c, http.StatusOK, "settings.html", gmap)
}

// UserLog will show the user their auditlog history (for all orgs)
func (s *Service) UserLog(c echo.Context) error {
	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	gmap, err := links.FetchAuditLogs(c, int(user.ID), "", 0, 0)
	if err != nil {
		return err
	}
	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
}

// CompleteRegister is used when a user has been invited to an organization but does not
// yet have an account. For normal user registration "completion", see
// gobwebs/accounts/routes.go
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 6a3ae3b..a00cc26 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -190,6 +190,7 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -384,6 +385,7 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input *model.
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -566,6 +568,7 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		userID,
@@ -772,6 +775,7 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	ltype := models.LOG_BOOKMARK_UPDATED
	ldet := "bookmark"
	if orgLink.Type == models.NoteType {
@@ -877,6 +881,7 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model.
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	ltype := models.LOG_BOOKMARK_DELETED
	ldet := "bookmark"
	if link.Type == models.NoteType {
@@ -1199,6 +1204,7 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["user_id"] = user.ID
	err = models.RecordAuditLog(
		ctx,
@@ -1302,6 +1308,7 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema

		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		mdata["org_slug"] = org.Slug
		mdata["user_id"] = duser.ID
		err = models.RecordAuditLog(
			ctx,
@@ -1430,6 +1437,7 @@ func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*mode
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -1904,6 +1912,7 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = personalOrg.ID
	mdata["org_slug"] = personalOrg.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -2057,6 +2066,7 @@ func (r *mutationResolver) AddDomain(ctx context.Context, input model.DomainInpu
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["domain_id"] = domain.ID
	err = models.RecordAuditLog(
		ctx,
@@ -2141,6 +2151,7 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = domain.OrgID
	mdata["org_slug"] = domain.OrgSlug
	mdata["domain_id"] = domain.ID
	err = models.RecordAuditLog(
		ctx,
@@ -2357,6 +2368,7 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -2552,6 +2564,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -2619,6 +2632,7 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model.

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -2935,7 +2949,9 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["list_id"] = listing.ID
	mdata["list_slug"] = listing.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -3037,7 +3053,9 @@ func (r *mutationResolver) AddListingLink(ctx context.Context, input *model.AddL
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["list_id"] = listing.ID
	mdata["list_slug"] = listing.Slug
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
@@ -3382,7 +3400,9 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["list_id"] = listing.ID
	mdata["list_slug"] = listing.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -3458,6 +3478,12 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
		return nil, nil
	}

	listing := &models.Listing{ID: listingLink.ListingID}
	err = listing.Load(ctx)
	if err != nil {
		return nil, err
	}

	listingLink.Title = input.Title
	listingLink.LinkOrder = input.LinkOrder
	listingLink.URL = input.URL
@@ -3472,7 +3498,8 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["list_id"] = listingLink.ListingID
	mdata["org_slug"] = org.Slug
	mdata["list_slug"] = listing.Slug
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
@@ -3542,7 +3569,9 @@ func (r *mutationResolver) DeleteListing(ctx context.Context, id int) (*model.De
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["list_id"] = listing.ID
	mdata["list_slug"] = listing.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -3604,6 +3633,12 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
		return nil, nil
	}

	listing := &models.Listing{ID: listingLink.ID}
	err = listing.Load(ctx)
	if err != nil {
		return nil, err
	}

	deletedID := strconv.Itoa(listingLink.ID)
	err = listingLink.Delete(ctx)
	if err != nil {
@@ -3613,14 +3648,16 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["list_id"] = listingLink.ListingID
	mdata["list_slug"] = listing.Slug
	mdata["list_link_id"] = listingLink.ID
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
		c.RealIP(),
		models.LOG_LIST_LINK_DELETED,
		fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
		fmt.Sprintf("Deleted listing entry '%s' (%d)", listingLink.Title, listingLink.ID),
		mdata,
	)
	if err != nil {
@@ -3905,6 +3942,7 @@ func (r *mutationResolver) AddQRCode(ctx context.Context, input model.AddQRCodeI

	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["qrcode_id"] = qr.ID
	mdata["qrcode_type"] = qr.CodeType
	err = models.RecordAuditLog(
@@ -3975,6 +4013,7 @@ func (r *mutationResolver) DeleteQRCode(ctx context.Context, id int) (*model.Del
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	mdata["qrcode_id"] = qrCode.ID
	mdata["qrcode_type"] = qrCode.CodeType
	err = models.RecordAuditLog(
@@ -4048,6 +4087,7 @@ func (r *mutationResolver) Follow(ctx context.Context, orgSlug string) (*model.F
		c := server.EchoForContext(ctx)
		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		mdata["org_slug"] = org.Slug
		err = models.RecordAuditLog(
			ctx,
			int(user.ID),
@@ -4125,6 +4165,7 @@ func (r *mutationResolver) Unfollow(ctx context.Context, orgSlug string) (*model
	c := server.EchoForContext(ctx)
	mdata := make(map[string]any)
	mdata["org_id"] = org.ID
	mdata["org_slug"] = org.Slug
	err = models.RecordAuditLog(
		ctx,
		int(user.ID),
@@ -6652,10 +6693,13 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
		return nil, nil
	}

	var org *models.Organization
	var err error
	var (
		org *models.Organization
		err error
	)
	opts := &database.FilterOptions{
		Filter: sq.And{},
		Filter:  sq.And{},
		OrderBy: "al.id DESC",
	}

	if input.OrgSlug != nil && *input.OrgSlug != "" {
@@ -6781,8 +6825,9 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI
	} else if input.Before != nil {
		opts.Filter = sq.And{
			opts.Filter,
			sq.Expr("ol.id >= ?", input.Before.Before),
			sq.Expr("al.id >= ?", input.Before.Before),
		}
		opts.OrderBy = "al.id ASC"
		numElements = input.Before.Limit
	}

diff --git a/cmd/migrations.go b/cmd/migrations.go
index 05c7bc5..43f3de1 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -59,5 +59,12 @@ func GetMigrations() []migrate.Migration {
			0,
			links.MigrateFS,
		),
		migrate.FSFileMigration(
			"0006_update_auditlog_metadata",
			"migrations/0006_update_auditlog_metadata.up.sql",
			"migrations/0006_update_auditlog_metadata.down.sql",
			0,
			links.MigrateFS,
		),
	}
}
diff --git a/core/import.go b/core/import.go
index 27c844d..b1a1129 100644
--- a/core/import.go
+++ b/core/import.go
@@ -478,6 +478,7 @@ func ImportFromPinBoard(ctx context.Context, path string,
	if totalCount > 0 {
		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		mdata["org_slug"] = org.Slug
		err := models.RecordAuditLog(
			ctx,
			int(user.ID),
@@ -625,6 +626,7 @@ func ImportFromHTML(ctx context.Context, path string,
	if listlen > 0 {
		mdata := make(map[string]any)
		mdata["org_id"] = org.ID
		mdata["org_slug"] = org.Slug
		err := models.RecordAuditLog(
			ctx,
			int(user.ID),
diff --git a/core/routes.go b/core/routes.go
index 3afa8f6..661c1d9 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -83,6 +83,7 @@ func (s *Service) RegisterRoutes() {
	s.Group.GET("/:slug/import", s.ImportData).Name = s.RouteName("import_data")
	s.Group.POST("/:slug/import", s.ImportData).Name = s.RouteName("import_data")
	s.Group.GET("/:slug/integrations", s.Integrations).Name = s.RouteName("integrations")
	s.Group.GET("/:slug/log", s.OrgLog).Name = s.RouteName("org_log")

	s.Group.GET("/home", s.OrgLinksList).Name = s.RouteName("home_link_list")
	s.Group.GET("/add", s.OrgLinksCreate).Name = s.RouteName("link_create")
@@ -368,6 +369,28 @@ func (s *Service) FeatureTour(c echo.Context) error {
	return s.Render(c, http.StatusOK, "feature_tour.html", gmap)
}

func (s *Service) OrgLog(c echo.Context) error {
	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)
	slug := c.Param("slug")
	userID := int(user.ID)

	org, err := user.GetOrgsSlug(c.Request().Context(), models.OrgUserPermissionAdminWrite, slug)
	if err != nil {
		return err
	}
	if org != nil {
		userID = 0 // Admin privileges can view all audit logs for org
	}

	gmap, err := links.FetchAuditLogs(c, userID, slug, 0, 0)
	if err != nil {
		return err
	}

	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
}

func (s *Service) DomainList(c echo.Context) error {
	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)
@@ -686,6 +709,7 @@ func (s *Service) OrgList(c echo.Context) error {
	pd.Data["import"] = lt.Translate("Import")
	pd.Data["integrations"] = lt.Translate("Integrations")
	pd.Data["payment_history"] = lt.Translate("Payment History")
	pd.Data["logs"] = lt.Translate("View Audit Logs")

	// If we want to highlight an org based on a given domain
	var dOrgSlug string
diff --git a/helpers.go b/helpers.go
index fd41437..e1da322 100644
--- a/helpers.go
+++ b/helpers.go
@@ -38,6 +38,7 @@ import (
	"golang.org/x/text/language"
	"golang.org/x/time/rate"
	"netlandish.com/x/gobwebs"
	auditlog "netlandish.com/x/gobwebs-auditlog"
	"netlandish.com/x/gobwebs/config"
	"netlandish.com/x/gobwebs/core"
	"netlandish.com/x/gobwebs/crypto"
@@ -1242,3 +1243,105 @@ func TagAbuseRedirect(c echo.Context) error {
	}
	return nil
}

// AuditLogResponse is a struct for auditlog gql query response storage
type AuditLogResponse struct {
	AuditLogs struct {
		Result   []auditlog.AuditLog `json:"result"`
		PageInfo struct {
			Cursor      string
			HasNextPage bool
			HasPrevPage bool
		} `json:"pageInfo"`
	} `json:"getAuditLogs"`
}

// FetchAuditLogs is helper to run a query fetching audit logs depending on various
// conditions.
func FetchAuditLogs(c echo.Context, userID int,
	orgSlug string, listID int, limit int) (gobwebs.Map, error) {
	op := gqlclient.NewOperation(
		`query GetAuditLogs($userId: Int, $slug: String, $listingId: Int, $after: Cursor, 
			$before: Cursor, $limit: Int) {
			getAuditLogs(input: {
				userId: $userId,
				orgSlug: $slug,
				listingId: $listingId,
				after: $after,
				before: $before,
				limit: $limit,
			}) {
				result {
					userId
					ipAddress
					eventType
					details
					metadata
					createdOn
				}
				pageInfo {
					cursor
					hasPrevPage
					hasNextPage
				}
			}
		}`)

	if userID > 0 {
		op.Var("userId", userID)
	}
	if orgSlug != "" {
		op.Var("slug", orgSlug)
	}
	if listID > 0 {
		op.Var("listingId", listID)
	}
	if limit > 0 {
		op.Var("limit", limit)
	}
	if c.QueryParam("next") != "" {
		op.Var("after", c.QueryParam("next"))
	} else if c.QueryParam("prev") != "" {
		op.Var("before", c.QueryParam("prev"))
	}

	var result AuditLogResponse
	err := Execute(LangContext(c), op, &result)
	if err != nil {
		return nil, err
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)
	if c.QueryParam("prev") != "" {
		slices.Reverse(result.AuditLogs.Result)
	}

	lt := localizer.GetSessionLocalizer(c)
	pd := localizer.NewPageData(lt.Translate("Audit Log"))
	pd.Data["ip_address"] = lt.Translate("IP Address")
	pd.Data["org"] = lt.Translate("Organization")
	pd.Data["listing"] = lt.Translate("Link Listing")
	pd.Data["event_type"] = lt.Translate("Action")
	pd.Data["details"] = lt.Translate("Details")
	pd.Data["timestamp"] = lt.Translate("Timestamp")
	pd.Data["no_logs"] = lt.Translate("No audit logs to display")
	pd.Data["next"] = lt.Translate("Next")
	pd.Data["prev"] = lt.Translate("Prev")
	gmap := gobwebs.Map{
		"pd":      pd,
		"user":    user,
		"logs":    result.AuditLogs.Result,
		"orgSlug": orgSlug,
		"listId":  listID,
	}
	if result.AuditLogs.PageInfo.HasPrevPage {
		gmap["prevURL"] = GetPaginationParams("prev", "", "", result.AuditLogs.PageInfo.Cursor)
	}

	if result.AuditLogs.PageInfo.HasNextPage {
		gmap["nextURL"] = GetPaginationParams("next", "", "", result.AuditLogs.PageInfo.Cursor)
	}

	return gmap, nil
}
diff --git a/list/routes.go b/list/routes.go
index 6af5cc6..09af3f9 100644
--- a/list/routes.go
+++ b/list/routes.go
@@ -39,6 +39,7 @@ func (s *Service) RegisterRoutes() {
	s.Group.GET("/:id/delete", s.ListingDelete).Name = s.RouteName("listing_delete")
	s.Group.POST("/:id/delete", s.ListingDelete).Name = s.RouteName("listing_delete")
	s.Group.GET("/:id/links", s.ListingLinksManage).Name = s.RouteName("listing_links")
	s.Group.GET("/:id/log", s.ListingLog).Name = s.RouteName("listing_log")
	s.Group.GET("/:id/links/add", s.ListingLinksCreate).Name = s.RouteName("listing_link_create")
	s.Group.POST("/:id/links/add", s.ListingLinksCreate).Name = s.RouteName("listing_link_create_post")
	s.Group.GET("/:id/links/:lid/edit", s.ListingLinksUpdate).Name = s.RouteName("listing_link_update")
@@ -51,6 +52,33 @@ func (s *Service) RegisterRoutes() {
	s.Group.GET("", s.ListingList).Name = s.RouteName("listing_list")
}

func (s *Service) ListingLog(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NotFoundHandler(c)
	}
	slug := c.Param("slug")

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)
	userID := int(user.ID)

	org, err := user.GetOrgsSlug(c.Request().Context(), models.OrgUserPermissionAdminWrite, slug)
	if err != nil {
		return err
	}
	if org != nil {
		userID = 0 // Admin privileges can view all audit logs for org
	}

	gmap, err := links.FetchAuditLogs(c, userID, slug, id, 0)
	if err != nil {
		return err
	}

	return s.Render(c, http.StatusOK, "auditlog.html", gmap)
}

func (s *Service) ListingLinksUpdate(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
@@ -1009,6 +1037,7 @@ func (s *Service) ListingList(c echo.Context) error {
	pd.Data["apply"] = lt.Translate("Apply")
	pd.Data["clear"] = lt.Translate("Clear")
	pd.Data["is_default"] = lt.Translate("Is Default")
	pd.Data["logs"] = lt.Translate("View Audit Logs")

	type GraphQLResponse struct {
		Listings struct {
diff --git a/migrations/0006_update_auditlog_metadata.down.sql b/migrations/0006_update_auditlog_metadata.down.sql
new file mode 100644
index 0000000..281a6d2
--- /dev/null
+++ b/migrations/0006_update_auditlog_metadata.down.sql
@@ -0,0 +1,2 @@
UPDATE audit_log SET metadata = metadata - 'org_slug' WHERE metadata ? 'org_slug';
UPDATE audit_log SET metadata = metadata - 'list_slug' WHERE metadata ? 'list_slug';
diff --git a/migrations/0006_update_auditlog_metadata.up.sql b/migrations/0006_update_auditlog_metadata.up.sql
new file mode 100644
index 0000000..66173cc
--- /dev/null
+++ b/migrations/0006_update_auditlog_metadata.up.sql
@@ -0,0 +1,10 @@
UPDATE audit_log a
SET metadata = jsonb_set(metadata, '{org_slug}', to_jsonb(o.slug))
FROM organizations o
WHERE (metadata->>'org_id')::INTEGER = o.id;

UPDATE audit_log a
SET metadata = jsonb_set(a.metadata, '{list_slug}', to_jsonb(l.slug))
FROM listings l
WHERE (a.metadata->>'list_id')::INTEGER = l.id;

diff --git a/templates/auditlog.html b/templates/auditlog.html
new file mode 100644
index 0000000..62220bc
--- /dev/null
+++ b/templates/auditlog.html
@@ -0,0 +1,47 @@
{{template "base" .}}
{{ define "title" }}{{ if gt .listId 0 }}{{ .pd.Data.listing }} {{ else if ne .orgSlug "" }}{{ .pd.Data.org }} {{ .orgSlug }} {{ end }}{{ .pd.Title }}{{ end }}
<section class="app-header">
  <h1 class="app-header__title">{{ if gt .listId 0 }}{{ .pd.Data.listing }} {{ else if ne .orgSlug "" }}{{ .pd.Data.org }} {{ .orgSlug }} {{ end }}{{ .pd.Title }}</h1>
</section>

<section class="card shadow-card">
  <table class="striped mt-1">
    <thead>
      <tr>
        <th class="text-center">{{.pd.Data.timestamp}}</th>
        <th class="text-center">{{.pd.Data.org}}</th>
        <th class="text-center">{{.pd.Data.event_type}}</th>
        <th class="text-center">{{.pd.Data.ip_address}}</th>
        <th class="text-center">{{.pd.Data.details}}</th>
      </tr>
    </thead>
    <tbody>
      {{ if .logs }}
          {{range .logs}}
          <tr>
              <td class="text-center">{{ formatDate .CreatedOn }}</td>
              <td class="text-center">{{ .Metadata.org_slug }}</td>
              <td class="text-center">{{ .EventType }}</td>
              <td class="text-center">{{ .IPAddress }}</td>
              <td class="text-center">{{ .Details }}</td>
          </tr>
          {{end}}
      {{else}}
        <tr>
            <td colspan="6"><p class="text-center">{{.pd.Data.no_logs}}</p></td>
        </tr>
      {{end}}
    </tbody>
  </table>
  {{if or .prevURL .nextURL}}
  <footer class="is-right">
    {{if .prevURL}}
    <a href="?{{.prevURL}}" class="button secondary">{{.pd.Data.prev}}</a>
    {{end}}
    {{if .nextURL}}
    <a href="?{{.nextURL}}" class="button secondary">{{.pd.Data.next}}</a>
    {{end}}
  </footer>
  {{end}}
</section>
{{template "base_footer" .}}
diff --git a/templates/listing_list.html b/templates/listing_list.html
index da23de4..838e598 100644
--- a/templates/listing_list.html
+++ b/templates/listing_list.html
@@ -86,6 +86,7 @@
                              <p><a class="mr-1" href="{{reverse "list:listing_update" $.org.Slug .ID}}">{{$.pd.Data.edit}}</a></p>
                              <p><a class="mr-1" href="{{reverse "list:listing_delete" $.org.Slug .ID}}">{{$.pd.Data.delete}}</a></p>
                              <p><a class="mr-1" href="{{reverse "analytics:detail" $.org.Slug "lists" .ID}}">{{$.pd.Data.analytics}}</a></p>
                              <p><a class="mr-1" href="{{reverse "list:listing_log" $.org.Slug .ID}}">{{$.pd.Data.logs}}</a></p>
                              {{end}}
                          </div>
                      </details>
diff --git a/templates/org_list.html b/templates/org_list.html
index df27a2e..227d14f 100644
--- a/templates/org_list.html
+++ b/templates/org_list.html
@@ -58,6 +58,7 @@
                            <p><a class="mr-1" href="{{reverse "core:domain_list" .Slug}}">{{$.pd.Data.domains}}</a></p>
                            <p><a class="mr-1" href="{{reverse "core:integrations" .Slug}}">{{$.pd.Data.integrations}}</a></p>
                            <p><a class="mr-1" href="{{reverse "core:org_member_list" .Slug}}">{{$.pd.Data.members}}</a></p>
                            <p><a class="mr-1" href="{{reverse "core:org_log" .Slug}}">{{$.pd.Data.logs}}</a></p>
                        {{end}}
                    </div>
                  </details>
diff --git a/templates/settings.html b/templates/settings.html
index b7e3598..43b0cb5 100644
--- a/templates/settings.html
+++ b/templates/settings.html
@@ -16,6 +16,7 @@
                            <p><a class="mr-1" href="{{reverse "accounts:profile_edit"}}">{{.pd.Data.edit_profile}}</a></p>
                            <p><a class="mr-1" href="{{reverse "accounts:update_email"}}">{{.pd.Data.update_email}}</a></p>
                            <p><a class="mr-1" href="{{reverse "accounts:change_password"}}">{{.pd.Data.change_password}}</a></p>
                            <p><a class="mr-1" href="{{reverse "accounts:settings_log"}}">{{.pd.Data.logs}}</a></p>
                        </div>
                    </details>
                </td>
-- 
2.47.2
Applied.

To git@git.code.netlandish.com:~netlandish/links
   9ac75f7..47a5324  master -> master